feat: fork
This commit is contained in:
@@ -1042,6 +1042,50 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|||||||
const appended = logOf(sessionId).at(-1) as SessionEvent
|
const appended = logOf(sessionId).at(-1) as SessionEvent
|
||||||
return ok(request, { title: normalized, seq: appended.seq })
|
return ok(request, { title: normalized, seq: appended.seq })
|
||||||
},
|
},
|
||||||
|
fork: (request) => {
|
||||||
|
const { sessionId, atSeq } = request.payload
|
||||||
|
const source = summaryOf(sessionId)
|
||||||
|
if (source === undefined) {
|
||||||
|
return err(request, {
|
||||||
|
code: 'session-not-found',
|
||||||
|
message: `no session ${sessionId}`,
|
||||||
|
details: { sessionId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const log = logs.get(sessionId) ?? []
|
||||||
|
// Host-parallel boundary: first turn/end at or after atSeq, falling
|
||||||
|
// back to the last completed turn; no completed turn = fork-unavailable.
|
||||||
|
const boundary = (atSeq === undefined ? undefined : log.find(e => e.type === 'turn/end' && e.seq >= atSeq))
|
||||||
|
?? log.findLast(e => e.type === 'turn/end')
|
||||||
|
if (boundary === undefined) {
|
||||||
|
return err(request, {
|
||||||
|
code: 'fork-unavailable',
|
||||||
|
message: `session ${sessionId} has no completed turn`,
|
||||||
|
details: { sessionId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let cut = boundary.seq + 1
|
||||||
|
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
|
||||||
|
const child: SessionSummary = {
|
||||||
|
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
|
||||||
|
parentSessionId: sessionId,
|
||||||
|
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||||
|
}
|
||||||
|
logs.set(child.sessionId, log.slice(0, cut))
|
||||||
|
sessions.push(child)
|
||||||
|
emitHost({
|
||||||
|
type: 'host/session-added', sessionId: child.sessionId, blank: false,
|
||||||
|
parentSessionId: sessionId,
|
||||||
|
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||||
|
})
|
||||||
|
const workspace = workspaces.find(w => w.sessionIds.includes(sessionId))
|
||||||
|
if (workspace !== undefined) {
|
||||||
|
workspace.sessionIds = [child.sessionId, ...workspace.sessionIds]
|
||||||
|
workspace.updatedAt = new Date().toISOString()
|
||||||
|
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
|
||||||
|
}
|
||||||
|
return ok(request, { sessionId: child.sessionId })
|
||||||
|
},
|
||||||
history: async (request) => {
|
history: async (request) => {
|
||||||
const log = logs.get(request.payload.sessionId) ?? []
|
const log = logs.get(request.payload.sessionId) ?? []
|
||||||
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
|
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
|
||||||
@@ -1591,6 +1635,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
|||||||
case 'session.models': return this.api.sessions.models(request)
|
case 'session.models': return this.api.sessions.models(request)
|
||||||
case 'session.selectModel': return this.api.sessions.selectModel(request)
|
case 'session.selectModel': return this.api.sessions.selectModel(request)
|
||||||
case 'session.rename': return this.api.sessions.rename(request)
|
case 'session.rename': return this.api.sessions.rename(request)
|
||||||
|
case 'session.fork': return this.api.sessions.fork(request)
|
||||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||||
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
|
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
|
||||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient {
|
|||||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||||
|
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||||
() => Promise.resolve(ok({
|
() => Promise.resolve(ok({
|
||||||
@@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient {
|
|||||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||||
|
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export interface ISessions {
|
|||||||
open(id: SessionId): void
|
open(id: SessionId): void
|
||||||
/** Clear the current selection into the no-session view state. */
|
/** Clear the current selection into the no-session view state. */
|
||||||
clear(): void
|
clear(): void
|
||||||
|
/**
|
||||||
|
* Fork a session from a completed-turn prefix of the source; on resolution
|
||||||
|
* the child is in the list store and `open()` can target it.
|
||||||
|
* @param opts - source session id and the optional event seq anchoring the
|
||||||
|
* cut (the boundary is the first turn/end at or after it).
|
||||||
|
* @returns the child session id.
|
||||||
|
*/
|
||||||
|
fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId>
|
||||||
/**
|
/**
|
||||||
* Register a per-session standard-props provider (hooks become `use<Name>`
|
* Register a per-session standard-props provider (hooks become `use<Name>`
|
||||||
* selector hooks on the render side; props spread verbatim).
|
* selector hooks on the render side; props spread verbatim).
|
||||||
|
|||||||
@@ -289,6 +289,36 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract session.fork; on success merge the child into summaries
|
||||||
|
* immediately (same synchronous-addressability guarantee as create). The
|
||||||
|
* child carries the source's history, so it is never blank; lineage rides
|
||||||
|
* parentSessionId so the list nests it under its source.
|
||||||
|
* @param opts - source session and the optional seq anchoring the cut.
|
||||||
|
* @returns the fork result (the child session id).
|
||||||
|
*/
|
||||||
|
async fork(
|
||||||
|
opts: { sessionId: SessionId; atSeq?: number },
|
||||||
|
): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||||
|
try {
|
||||||
|
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
|
||||||
|
const { result } = await this.api.sessions.fork({
|
||||||
|
sessionId: opts.sessionId,
|
||||||
|
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
|
||||||
|
})
|
||||||
|
if (result.ok) {
|
||||||
|
this.recordMutation({ kind: 'upsert', summary: {
|
||||||
|
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: false,
|
||||||
|
parentSessionId: opts.sessionId,
|
||||||
|
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
||||||
|
} })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
return transportError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
|
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
|
||||||
* existing entry only gains fields it lacks (the session-added frame and the
|
* existing entry only gains fields it lacks (the session-added frame and the
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ export class SessionCreateError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Structured session-fork failure. */
|
||||||
|
export class SessionForkError extends Error {
|
||||||
|
override readonly name = 'SessionForkError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param rpcError - Host business or folded transport error.
|
||||||
|
* @param sourceSessionId - the session the fork was cut from.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
readonly rpcError: RpcError,
|
||||||
|
readonly sourceSessionId: SessionId,
|
||||||
|
) {
|
||||||
|
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||||
export interface SessionBinding {
|
export interface SessionBinding {
|
||||||
readonly sessionId: SessionId
|
readonly sessionId: SessionId
|
||||||
@@ -317,6 +333,22 @@ export class SessionsService implements ISessions {
|
|||||||
return result.value.sessionId
|
return result.value.sessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fork a session from a completed-turn prefix of the source (same
|
||||||
|
* synchronous-addressability guarantee as {@link SessionsService.create}:
|
||||||
|
* on resolution the child is in the list store and open() can target it).
|
||||||
|
* @param opts - source session id and the optional event seq anchoring the
|
||||||
|
* cut (the boundary is the first turn/end at or after it).
|
||||||
|
* @returns the child session id.
|
||||||
|
* @throws {SessionForkError} with the source id.
|
||||||
|
*/
|
||||||
|
async fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId> {
|
||||||
|
const result = await this.manager.fork(opts)
|
||||||
|
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
|
||||||
|
this.projectList()
|
||||||
|
return result.value.sessionId
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve an Agent-scoped context view (use-and-discard).
|
* Resolve an Agent-scoped context view (use-and-discard).
|
||||||
* @param id - session id (the agent identity — 1:1 same axis).
|
* @param id - session id (the agent identity — 1:1 same axis).
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient {
|
|||||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||||
|
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||||
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
|
|||||||
selectModel: (payload: { provider: string; model: string }) =>
|
selectModel: (payload: { provider: string; model: string }) =>
|
||||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||||
|
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export class TestSessions implements ISessions {
|
|||||||
private readonly channel: SessionProvideChannel
|
private readonly channel: SessionProvideChannel
|
||||||
|
|
||||||
/** Calls observed on the service-level face (open/clear), newest last. */
|
/** Calls observed on the service-level face (open/clear), newest last. */
|
||||||
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
|
readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param stabilize - the owning runtime's act wrapper.
|
* @param stabilize - the owning runtime's act wrapper.
|
||||||
@@ -392,6 +392,17 @@ export class TestSessions implements ISessions {
|
|||||||
this.list.update((draft) => { draft.current = undefined })
|
this.list.update((draft) => { draft.current = undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recorded fork stub: no child materializes (benches asserting the full
|
||||||
|
* fork flow drive the production service; this face only proves the call).
|
||||||
|
* @param opts - source session id and optional cut anchor.
|
||||||
|
* @returns the source id (no child record is created).
|
||||||
|
*/
|
||||||
|
fork(opts: { sessionId: SessionId; atSeq?: number }): Promise<SessionId> {
|
||||||
|
this.calls.push({ method: 'fork', args: [opts] })
|
||||||
|
return Promise.resolve(opts.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The session face of a fixture (typed view for assertions; fixture
|
* The session face of a fixture (typed view for assertions; fixture
|
||||||
* behavior methods are grafted onto it).
|
* behavior methods are grafted onto it).
|
||||||
|
|||||||
@@ -262,6 +262,13 @@ export function apply(ctx: Context): void {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
loadOlder: () => { void scoped.loadOlder() },
|
loadOlder: () => { void scoped.loadOlder() },
|
||||||
|
forkAt: (seq) => {
|
||||||
|
sessions.fork({ sessionId, atSeq: seq })
|
||||||
|
.then((childId) => { sessions.open(childId) })
|
||||||
|
.catch(() => {
|
||||||
|
// Fork failure keeps the source view untouched (composer-stop posture).
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, ChatView)
|
}, ChatView)
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ function StreamingTail({ useSession, onGrow }: {
|
|||||||
* The chat view slot entry: pure component over the composed props (tool rows
|
* The chat view slot entry: pure component over the composed props (tool rows
|
||||||
* render through the declared keyed hole's renderSlot share).
|
* render through the declared keyed hole's renderSlot share).
|
||||||
*/
|
*/
|
||||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
|
||||||
const nodes = useSession(s => s.nodes)
|
const nodes = useSession(s => s.nodes)
|
||||||
// Workspace root off the session list row: path summaries display relative to it.
|
// Workspace root off the session list row: path summaries display relative to it.
|
||||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||||
@@ -385,7 +385,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
|||||||
}
|
}
|
||||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||||
if (node.kind === 'tool-result') return null
|
if (node.kind === 'tool-result') return null
|
||||||
return <MessageItem key={item.key} node={node} />
|
return <MessageItem key={item.key} node={node} onFork={forkAt} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Shared IconActions chrome for user and assistant messages: copy / branch
|
// Shared IconActions chrome for user and assistant messages: copy live,
|
||||||
// live (branch still a stub), date-aware clock, optional edit stub.
|
// branch wired through onBranch (stub without it), date-aware clock,
|
||||||
|
// optional edit stub.
|
||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -18,17 +19,19 @@ export interface MessageIconActionsProps {
|
|||||||
clock: 'start' | 'end'
|
clock: 'start' | 'end'
|
||||||
/** When true, append the stub edit control (user bubble). */
|
/** When true, append the stub edit control (user bubble). */
|
||||||
edit?: boolean | undefined
|
edit?: boolean | undefined
|
||||||
|
/** Fork the session at this message; absent leaves the branch control a stub. */
|
||||||
|
onBranch?: (() => void) | undefined
|
||||||
/** Parent layout class composed onto the actions row. */
|
/** Parent layout class composed onto the actions row. */
|
||||||
className?: string | undefined
|
className?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||||
* @param props - Copy text, event time, clock side, optional edit, className.
|
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
|
||||||
* @returns The actions row element.
|
* @returns The actions row element.
|
||||||
*/
|
*/
|
||||||
export function MessageIconActions({
|
export function MessageIconActions({
|
||||||
text, time, clock, edit, className,
|
text, time, clock, edit, onBranch, className,
|
||||||
}: MessageIconActionsProps) {
|
}: MessageIconActionsProps) {
|
||||||
const day = useCalendarDay()
|
const day = useCalendarDay()
|
||||||
const onCopy = useCallback(() => {
|
const onCopy = useCallback(() => {
|
||||||
@@ -48,7 +51,7 @@ export function MessageIconActions({
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="在新对话中分支" side="bottom">
|
<Tooltip label="在新对话中分支" side="bottom">
|
||||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
|
||||||
<IconBranchOutline16 />
|
<IconBranchOutline16 />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import css from './MessageItem.module.css'
|
|||||||
|
|
||||||
export interface MessageItemProps {
|
export interface MessageItemProps {
|
||||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
|
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
|
||||||
|
/** Fork the session through the turn containing this message (user-bubble branch action). */
|
||||||
|
onFork?: (seq: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||||
@@ -61,7 +63,7 @@ function projectUserText(text: string): ReactNode {
|
|||||||
return <>{parts}</>
|
return <>{parts}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case 'user': {
|
case 'user': {
|
||||||
const { text, rest } = contentText(node.content)
|
const { text, rest } = contentText(node.content)
|
||||||
@@ -76,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
|||||||
time={node.time}
|
time={node.time}
|
||||||
clock="start"
|
clock="start"
|
||||||
edit
|
edit
|
||||||
|
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||||
className={css.actions}
|
className={css.actions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -419,6 +419,8 @@ export interface ChatViewInjected {
|
|||||||
*/
|
*/
|
||||||
openFile: (path: string) => void
|
openFile: (path: string) => void
|
||||||
loadOlder: () => void
|
loadOlder: () => void
|
||||||
|
/** Fork the session through the turn containing the message at `seq`, then open the child. */
|
||||||
|
forkAt: (seq: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
|
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|||||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||||
const openFile = vi.fn<(path: string) => void>()
|
const openFile = vi.fn<(path: string) => void>()
|
||||||
const loadOlder = vi.fn()
|
const loadOlder = vi.fn()
|
||||||
|
const forkAt = vi.fn()
|
||||||
// Selection rides the REAL chat store (same construction path as
|
// Selection rides the REAL chat store (same construction path as
|
||||||
// production; the view reads it through the PropsStore useStore share).
|
// production; the view reads it through the PropsStore useStore share).
|
||||||
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
||||||
@@ -120,9 +121,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
|||||||
openDetails,
|
openDetails,
|
||||||
openFile,
|
openFile,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
|
forkAt,
|
||||||
}
|
}
|
||||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('chat-flow derivation', () => {
|
describe('chat-flow derivation', () => {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ interface DragState {
|
|||||||
|
|
||||||
type SessionTreeProps = Pick<
|
type SessionTreeProps = Pick<
|
||||||
WorkspaceBrowserProps,
|
WorkspaceBrowserProps,
|
||||||
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
|
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore'
|
||||||
> & {
|
> & {
|
||||||
workspaces: readonly WorkspaceView[]
|
workspaces: readonly WorkspaceView[]
|
||||||
/** Live search filter owned by the browser root (the query outlives the tree). */
|
/** Live search filter owned by the browser root (the query outlives the tree). */
|
||||||
@@ -98,7 +98,7 @@ type SessionTreeProps = Pick<
|
|||||||
|
|
||||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||||
function SessionTree({
|
function SessionTree({
|
||||||
useSessions, startSession, open, workspaces, query,
|
useSessions, startSession, open, forkSession, workspaces, query,
|
||||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
|
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
|
||||||
}: SessionTreeProps) {
|
}: SessionTreeProps) {
|
||||||
const list = useSessions(s => s)
|
const list = useSessions(s => s)
|
||||||
@@ -115,6 +115,24 @@ function SessionTree({
|
|||||||
if (current === undefined || currentGroup === undefined) return
|
if (current === undefined || currentGroup === undefined) return
|
||||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||||
}, [current, currentGroup])
|
}, [current, currentGroup])
|
||||||
|
// The selected session must be visible: unfold its ancestor chain (fork
|
||||||
|
// lands the child under a possibly folded parent row).
|
||||||
|
const currentAncestors = useMemo(() => {
|
||||||
|
const chain: string[] = []
|
||||||
|
let cursor = current === undefined ? undefined : list.byId[current]?.parentId
|
||||||
|
while (cursor !== undefined && !chain.includes(cursor)) {
|
||||||
|
chain.push(cursor)
|
||||||
|
cursor = list.byId[cursor]?.parentId
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
}, [current, list])
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentAncestors.length === 0) return
|
||||||
|
setExpandedSessions((l) => {
|
||||||
|
const missing = currentAncestors.filter(id => !l.includes(id))
|
||||||
|
return missing.length === 0 ? l : [...l, ...missing]
|
||||||
|
})
|
||||||
|
}, [currentAncestors])
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||||
[list, workspaces, expandedProjects, expandedSessions, query],
|
[list, workspaces, expandedProjects, expandedSessions, query],
|
||||||
@@ -195,6 +213,7 @@ function SessionTree({
|
|||||||
now={now}
|
now={now}
|
||||||
onOpen={open}
|
onOpen={open}
|
||||||
onRename={onSessionRename}
|
onRename={onSessionRename}
|
||||||
|
onFork={forkSession}
|
||||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||||
drag={dragProps}
|
drag={dragProps}
|
||||||
/>
|
/>
|
||||||
@@ -209,7 +228,7 @@ function SessionTree({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||||
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
|
function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query'>) {
|
||||||
const list = useSessions(s => s)
|
const list = useSessions(s => s)
|
||||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -228,6 +247,7 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTre
|
|||||||
now={now}
|
now={now}
|
||||||
onOpen={open}
|
onOpen={open}
|
||||||
onRename={onSessionRename}
|
onRename={onSessionRename}
|
||||||
|
onFork={forkSession}
|
||||||
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
||||||
onToggle={() => {}}
|
onToggle={() => {}}
|
||||||
flat
|
flat
|
||||||
@@ -254,6 +274,7 @@ export function WorkspaceBrowser({
|
|||||||
startSession,
|
startSession,
|
||||||
open,
|
open,
|
||||||
renameSession,
|
renameSession,
|
||||||
|
forkSession,
|
||||||
renameWorkspace,
|
renameWorkspace,
|
||||||
deleteWorkspace,
|
deleteWorkspace,
|
||||||
insertSessionBefore,
|
insertSessionBefore,
|
||||||
@@ -460,13 +481,14 @@ export function WorkspaceBrowser({
|
|||||||
|
|
||||||
{/* Always-mounted seat keeps the region's flex slot while the list
|
{/* Always-mounted seat keeps the region's flex slot while the list
|
||||||
itself is wide-only. */}
|
itself is wide-only. */}
|
||||||
<div className={css.listArea}>
|
<div className={css.listArea}>
|
||||||
{wide && (groupBy === 'flat'
|
{wide && (groupBy === 'flat'
|
||||||
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
|
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
|
||||||
: (
|
: (
|
||||||
<SessionTree
|
<SessionTree
|
||||||
useSessions={useSessions}
|
useSessions={useSessions}
|
||||||
onSessionRename={onSessionRename}
|
onSessionRename={onSessionRename}
|
||||||
|
forkSession={forkSession}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
startSession={startSession}
|
startSession={startSession}
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
|||||||
open: (sessionId: SessionId) => void
|
open: (sessionId: SessionId) => void
|
||||||
/** Rename a Session (explicit user title; resolves on host acceptance). */
|
/** Rename a Session (explicit user title; resolves on host acceptance). */
|
||||||
renameSession: (sessionId: SessionId, title: string) => Promise<void>
|
renameSession: (sessionId: SessionId, title: string) => Promise<void>
|
||||||
|
/** Fork a Session at its last completed turn and open the child. */
|
||||||
|
forkSession: (sessionId: SessionId) => void
|
||||||
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
||||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export function apply(ctx: ClientContext): void {
|
|||||||
const result = await session.rename(title)
|
const result = await session.rename(title)
|
||||||
if (!result.ok) throw new Error(result.error.message)
|
if (!result.ok) throw new Error(result.error.message)
|
||||||
},
|
},
|
||||||
|
forkSession: (sessionId) => {
|
||||||
|
ctx.sessions.fork({ sessionId })
|
||||||
|
.then((childId) => { ctx.sessions.open(childId) })
|
||||||
|
.catch(() => {
|
||||||
|
// Fork failure keeps the list untouched (composer-stop posture).
|
||||||
|
})
|
||||||
|
},
|
||||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||||
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
||||||
* except workspace Rename/Delete and session Rename; the session and workspace
|
* except workspace Rename/Delete and session Rename/Fork; the session and
|
||||||
* hover cards are suppressed while a menu is open.
|
* workspace hover cards are suppressed while a menu is open.
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -184,7 +184,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
|||||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
|
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onFork, onToggle, drag, flat = false }: {
|
||||||
node: SessionNode
|
node: SessionNode
|
||||||
depth: number
|
depth: number
|
||||||
currentId: string | undefined
|
currentId: string | undefined
|
||||||
@@ -192,6 +192,8 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
|||||||
onOpen: (id: SessionNode['id']) => void
|
onOpen: (id: SessionNode['id']) => void
|
||||||
/** Open the browser-owned session rename dialog (row menu action). */
|
/** Open the browser-owned session rename dialog (row menu action). */
|
||||||
onRename: (id: SessionNode['id'], currentTitle: string) => void
|
onRename: (id: SessionNode['id'], currentTitle: string) => void
|
||||||
|
/** Fork a session at its last completed turn (row menu action). */
|
||||||
|
onFork: (id: SessionNode['id']) => void
|
||||||
onToggle: (id: SessionNode['id']) => void
|
onToggle: (id: SessionNode['id']) => void
|
||||||
/** Present only on draggable rows (workspace-group roots outside search). */
|
/** Present only on draggable rows (workspace-group roots outside search). */
|
||||||
drag?: RowDragProps | undefined
|
drag?: RowDragProps | undefined
|
||||||
@@ -259,9 +261,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
|||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
onClose={() => { setMenuOpen(false) }}
|
onClose={() => { setMenuOpen(false) }}
|
||||||
items={SESSION_MENU_ITEMS}
|
items={SESSION_MENU_ITEMS}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
|
if (id === 'rename') onRename(node.id, row.title)
|
||||||
|
if (id === 'fork') onFork(node.id) // delete stays visual-only.
|
||||||
}}
|
}}
|
||||||
portal
|
portal
|
||||||
closeOnPointerLeave
|
closeOnPointerLeave
|
||||||
@@ -295,6 +298,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename,
|
|||||||
now={now}
|
now={now}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
|
onFork={onFork}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ describe('workspace browser rows', () => {
|
|||||||
}
|
}
|
||||||
const onOpen = vi.fn()
|
const onOpen = vi.fn()
|
||||||
const onToggle = vi.fn()
|
const onToggle = vi.fn()
|
||||||
const view = render(
|
const view = render(
|
||||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||||
onRename={vi.fn()} onToggle={onToggle} />,
|
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle} />,
|
||||||
)
|
)
|
||||||
|
|
||||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||||
@@ -88,9 +88,9 @@ describe('workspace browser rows', () => {
|
|||||||
|
|
||||||
view.rerender(
|
view.rerender(
|
||||||
<SessionNodeItem
|
<SessionNodeItem
|
||||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||||
onRename={vi.fn()} onToggle={onToggle}
|
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||||
@@ -162,9 +162,9 @@ describe('workspace browser rows', () => {
|
|||||||
const node: SessionNode = {
|
const node: SessionNode = {
|
||||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||||
expanded: false, running: false, updatedAt: 0,
|
expanded: false, running: false, updatedAt: 0,
|
||||||
}
|
}
|
||||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||||
onRename={onRename} onToggle={vi.fn()} />)
|
onRename={onRename} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||||
expect(onOpen).not.toHaveBeenCalled()
|
expect(onOpen).not.toHaveBeenCalled()
|
||||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
||||||
@@ -189,9 +189,9 @@ describe('workspace browser rows', () => {
|
|||||||
const node: SessionNode = {
|
const node: SessionNode = {
|
||||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||||
expanded: false, running: false, updatedAt: 0,
|
expanded: false, running: false, updatedAt: 0,
|
||||||
}
|
}
|
||||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||||
onRename={vi.fn()} onToggle={vi.fn()} flat />)
|
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} flat />)
|
||||||
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -201,9 +201,9 @@ describe('workspace browser rows', () => {
|
|||||||
const node: SessionNode = {
|
const node: SessionNode = {
|
||||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||||
expanded: false, running: true, updatedAt: 0,
|
expanded: false, running: true, updatedAt: 0,
|
||||||
}
|
}
|
||||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||||
fireEvent.pointerEnter(wrapper)
|
fireEvent.pointerEnter(wrapper)
|
||||||
act(() => { vi.advanceTimersByTime(500) })
|
act(() => { vi.advanceTimersByTime(500) })
|
||||||
@@ -228,9 +228,9 @@ describe('workspace browser rows', () => {
|
|||||||
const node: SessionNode = {
|
const node: SessionNode = {
|
||||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||||
expanded: false, running: false, updatedAt: 0,
|
expanded: false, running: false, updatedAt: 0,
|
||||||
}
|
}
|
||||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
|
||||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||||
act(() => { vi.advanceTimersByTime(500) })
|
act(() => { vi.advanceTimersByTime(500) })
|
||||||
expect(screen.getByText('Idle')).toBeTruthy()
|
expect(screen.getByText('Idle')).toBeTruthy()
|
||||||
@@ -246,9 +246,9 @@ describe('workspace browser rows', () => {
|
|||||||
expanded: false, running: false, updatedAt: 0,
|
expanded: false, running: false, updatedAt: 0,
|
||||||
}
|
}
|
||||||
const inactive = dragProps()
|
const inactive = dragProps()
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||||
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||||
)
|
)
|
||||||
const row = screen.getByRole('treeitem')
|
const row = screen.getByRole('treeitem')
|
||||||
stubRect(row)
|
stubRect(row)
|
||||||
@@ -264,9 +264,9 @@ describe('workspace browser rows', () => {
|
|||||||
expect(inactive.end).toHaveBeenCalledOnce()
|
expect(inactive.end).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
const active = dragProps({ active: true, marker: 'before' })
|
const active = dragProps({ active: true, marker: 'before' })
|
||||||
rerender(
|
rerender(
|
||||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||||
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||||
)
|
)
|
||||||
stubRect(screen.getByRole('treeitem'))
|
stubRect(screen.getByRole('treeitem'))
|
||||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||||
@@ -278,9 +278,9 @@ describe('workspace browser rows', () => {
|
|||||||
expect(active.drop).toHaveBeenCalledWith('after')
|
expect(active.drop).toHaveBeenCalledWith('after')
|
||||||
|
|
||||||
const after = dragProps({ active: true, marker: 'after' })
|
const after = dragProps({ active: true, marker: 'after' })
|
||||||
rerender(
|
rerender(
|
||||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||||
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||||
)
|
)
|
||||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
|||||||
startSession: vi.fn(),
|
startSession: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
renameSession: vi.fn(async () => {}),
|
renameSession: vi.fn(async () => {}),
|
||||||
|
forkSession: vi.fn(),
|
||||||
renameWorkspace: vi.fn(async () => {}),
|
renameWorkspace: vi.fn(async () => {}),
|
||||||
deleteWorkspace: vi.fn(async () => {}),
|
deleteWorkspace: vi.fn(async () => {}),
|
||||||
insertSessionBefore: vi.fn(async () => {}),
|
insertSessionBefore: vi.fn(async () => {}),
|
||||||
|
|||||||
@@ -1148,6 +1148,66 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fork(request) {
|
||||||
|
const { sessionId, atSeq } = request.payload
|
||||||
|
const found = await agentFor(sessionId)
|
||||||
|
if ('error' in found) return err(request, found.error)
|
||||||
|
const source = found.agent.session
|
||||||
|
const events = source.events
|
||||||
|
// Boundary: the first turn/end at or after atSeq (fork includes that
|
||||||
|
// whole turn); an overshooting atSeq or an omitted one falls back to
|
||||||
|
// the last completed turn.
|
||||||
|
const boundary = (atSeq === undefined ? undefined : events.find(e => e.type === 'turn/end' && e.seq >= atSeq))
|
||||||
|
?? events.findLast(e => e.type === 'turn/end')
|
||||||
|
if (boundary === undefined) {
|
||||||
|
return err(request, {
|
||||||
|
code: 'fork-unavailable',
|
||||||
|
message: `session "${sessionId}" has no completed turn to fork from`,
|
||||||
|
details: { sessionId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Extend the cut through trailing out-of-band appends (session/title,
|
||||||
|
// injections) up to the next turn/start: they are standalone events, so
|
||||||
|
// the seed stays balanced, and the child inherits a title generated
|
||||||
|
// right after the boundary turn.
|
||||||
|
let cut = boundary.seq + 1
|
||||||
|
while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
|
||||||
|
const childId = `session-${randomUUID()}` as SessionId
|
||||||
|
try {
|
||||||
|
await ctx.agents.create({
|
||||||
|
sessionId: childId,
|
||||||
|
seed: events.slice(0, cut),
|
||||||
|
meta: {
|
||||||
|
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
|
||||||
|
parentSession: source.id,
|
||||||
|
seedLength: cut,
|
||||||
|
},
|
||||||
|
agentOptions,
|
||||||
|
})
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return err(request, {
|
||||||
|
code: 'internal',
|
||||||
|
message: `failed to fork session "${sessionId}": ${String(error)}`,
|
||||||
|
details: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Keep the child in the source's Workspace so the list nests it under
|
||||||
|
// its parent; the child is already published if the attach fails.
|
||||||
|
const workspace = ctx.workspace.list().find(w => w.sessionIds.includes(source.id))
|
||||||
|
if (workspace !== undefined) {
|
||||||
|
try {
|
||||||
|
await workspace.attachSession(childId)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return err(request, {
|
||||||
|
code: 'workspace-attach-failed',
|
||||||
|
message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
||||||
|
details: { sessionId: childId, workspaceId: workspace.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok(request, { sessionId: childId })
|
||||||
|
},
|
||||||
|
|
||||||
async prompt(request) {
|
async prompt(request) {
|
||||||
const { sessionId, mode, content } = request.payload
|
const { sessionId, mode, content } = request.payload
|
||||||
const found = await agentFor(sessionId)
|
const found = await agentFor(sessionId)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface RpcMethodMap {
|
|||||||
'session.models': SessionsApi['models']
|
'session.models': SessionsApi['models']
|
||||||
'session.selectModel': SessionsApi['selectModel']
|
'session.selectModel': SessionsApi['selectModel']
|
||||||
'session.rename': SessionsApi['rename']
|
'session.rename': SessionsApi['rename']
|
||||||
|
'session.fork': SessionsApi['fork']
|
||||||
'session.prompt': SessionsApi['prompt']
|
'session.prompt': SessionsApi['prompt']
|
||||||
'session.updateQueue': SessionsApi['updateQueue']
|
'session.updateQueue': SessionsApi['updateQueue']
|
||||||
'session.cancel': SessionsApi['cancel']
|
'session.cancel': SessionsApi['cancel']
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
|||||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||||
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||||
|
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||||
]) as unknown as z.ZodType<RpcError>
|
]) as unknown as z.ZodType<RpcError>
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface RpcErrorDetailsMap {
|
|||||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||||
'unknown-command': {}
|
'unknown-command': {}
|
||||||
'title-invalid': { sessionId: SessionId }
|
'title-invalid': { sessionId: SessionId }
|
||||||
|
'fork-unavailable': { sessionId: SessionId }
|
||||||
'internal': {}
|
'internal': {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,17 @@ export const sessionRenameValueSchema = z.object({
|
|||||||
seq: z.number().int().nonnegative(),
|
seq: z.number().int().nonnegative(),
|
||||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
|
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
|
||||||
|
|
||||||
|
/** session.fork request payload (atSeq anchors the completed-turn cut). */
|
||||||
|
export const sessionForkRequestSchema = z.object({
|
||||||
|
sessionId: sessionIdSchema,
|
||||||
|
atSeq: z.number().int().nonnegative().optional(),
|
||||||
|
}) satisfies z.ZodType<Wire<RequestPayload<'session.fork'>>>
|
||||||
|
|
||||||
|
/** session.fork response value (the child session id). */
|
||||||
|
export const sessionForkValueSchema = z.object({
|
||||||
|
sessionId: sessionIdSchema,
|
||||||
|
}) satisfies z.ZodType<Wire<ResponseValue<'session.fork'>>>
|
||||||
|
|
||||||
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
|
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
|
||||||
export const sessionHistoryRequestSchema = z.object({
|
export const sessionHistoryRequestSchema = z.object({
|
||||||
sessionId: sessionIdSchema,
|
sessionId: sessionIdSchema,
|
||||||
|
|||||||
@@ -238,6 +238,20 @@ export interface SessionsApi {
|
|||||||
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
|
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
|
||||||
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
|
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Forks a new session from a completed-turn prefix of the source. `atSeq`
|
||||||
|
* anchors the cut: the boundary is the first `turn/end` at or after it
|
||||||
|
* (a message's fork button passes the message seq, so the fork includes
|
||||||
|
* that whole turn); a boundary past the log end, or an omitted `atSeq`,
|
||||||
|
* falls back to the source's last completed turn. A source with no
|
||||||
|
* completed turn fails with `fork-unavailable`. The child inherits the
|
||||||
|
* source cwd (and its workspace attachment) and records
|
||||||
|
* `parentSessionId` lineage; the seed prefix carries the source title.
|
||||||
|
*/
|
||||||
|
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
|
||||||
|
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||||
|
|
||||||
|
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
|
||||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
sessionCancelValueSchema,
|
sessionCancelValueSchema,
|
||||||
sessionCreateValueSchema,
|
sessionCreateValueSchema,
|
||||||
|
sessionForkValueSchema,
|
||||||
sessionHistoryValueSchema,
|
sessionHistoryValueSchema,
|
||||||
sessionListValueSchema,
|
sessionListValueSchema,
|
||||||
sessionModelsValueSchema,
|
sessionModelsValueSchema,
|
||||||
@@ -69,6 +70,7 @@ export interface IApiClient {
|
|||||||
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
||||||
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
|
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
|
||||||
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
|
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
|
||||||
|
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
|
||||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||||
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
|
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
|
||||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||||
@@ -121,6 +123,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
|||||||
'session.models': sessionModelsValueSchema,
|
'session.models': sessionModelsValueSchema,
|
||||||
'session.selectModel': sessionSelectModelValueSchema,
|
'session.selectModel': sessionSelectModelValueSchema,
|
||||||
'session.rename': sessionRenameValueSchema,
|
'session.rename': sessionRenameValueSchema,
|
||||||
|
'session.fork': sessionForkValueSchema,
|
||||||
'session.prompt': sessionPromptValueSchema,
|
'session.prompt': sessionPromptValueSchema,
|
||||||
'session.updateQueue': sessionUpdateQueueValueSchema,
|
'session.updateQueue': sessionUpdateQueueValueSchema,
|
||||||
'session.cancel': sessionCancelValueSchema,
|
'session.cancel': sessionCancelValueSchema,
|
||||||
@@ -334,6 +337,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
|||||||
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
||||||
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
|
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
|
||||||
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
|
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
|
||||||
|
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
|
||||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||||
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
|
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
|
||||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
|
|||||||
import {
|
import {
|
||||||
sessionCancelRequestSchema,
|
sessionCancelRequestSchema,
|
||||||
sessionCreateRequestSchema,
|
sessionCreateRequestSchema,
|
||||||
|
sessionForkRequestSchema,
|
||||||
sessionHistoryRequestSchema,
|
sessionHistoryRequestSchema,
|
||||||
sessionListRequestSchema,
|
sessionListRequestSchema,
|
||||||
sessionModelsRequestSchema,
|
sessionModelsRequestSchema,
|
||||||
@@ -71,6 +72,7 @@ const UNARY_ROUTES: UnaryRoutes = {
|
|||||||
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
||||||
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
|
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
|
||||||
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
|
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
|
||||||
|
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
|
||||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||||
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
|
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
|
||||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ function scriptedApi(overrides: {
|
|||||||
selected: { provider: r.payload.provider, model: r.payload.model },
|
selected: { provider: r.payload.provider, model: r.payload.model },
|
||||||
}),
|
}),
|
||||||
rename: r => ok(r, { title: 'renamed', seq: 0 }),
|
rename: r => ok(r, { title: 'renamed', seq: 0 }),
|
||||||
|
fork: r => ok(r, { sessionId: sid('s-fork') }),
|
||||||
prompt: r => ok(r, { accepted: true as const }),
|
prompt: r => ok(r, { accepted: true as const }),
|
||||||
updateQueue: r => ok(r, { accepted: true as const }),
|
updateQueue: r => ok(r, { accepted: true as const }),
|
||||||
cancel: r => ok(r, { accepted: true as const }),
|
cancel: r => ok(r, { accepted: true as const }),
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
|||||||
async rename(request) {
|
async rename(request) {
|
||||||
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
|
return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
|
||||||
},
|
},
|
||||||
|
async fork(request) {
|
||||||
|
return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } }
|
||||||
|
},
|
||||||
async prompt(request) {
|
async prompt(request) {
|
||||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user