diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cb79a6b9a2..282dfbee56 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1042,6 +1042,50 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const appended = logOf(sessionId).at(-1) as SessionEvent 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) => { const log = logs.get(request.payload.sessionId) ?? [] // 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.selectModel': return this.api.sessions.selectModel(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.updateQueue': return this.api.sessions.updateQueue(request) case 'session.cancel': return this.api.sessions.cancel(request) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 89bcb9301d..8e99d9f9bb 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) + onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => Promise.resolve(ok({ @@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient { selectModel: (payload: ModelTarget & { sessionId: SessionId }) => this.record('session.selectModel', payload, this.onSelectModel(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)), updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index d26b392072..7c147df80c 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -29,6 +29,14 @@ export interface ISessions { open(id: SessionId): void /** Clear the current selection into the no-session view state. */ 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 /** * Register a per-session standard-props provider (hooks become `use` * selector hooks on the render side; props spread verbatim). diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ff68e21921..f60d7b51e8 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -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> { + 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 * existing entry only gains fields it lacks (the session-added frame and the diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 71067d6330..b5bf951605 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -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). */ export interface SessionBinding { readonly sessionId: SessionId @@ -317,6 +333,22 @@ export class SessionsService implements ISessions { 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 { + 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). * @param id - session id (the agent identity — 1:1 same axis). diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index eb2a06294e..85dd4c5a55 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient { onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) + onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) @@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient { selectModel: (payload: { provider: string; model: string }) => this.record('session.selectModel', payload, this.onSelectModel(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)), updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 4fdfb32cc0..f585772b38 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -169,7 +169,7 @@ export class TestSessions implements ISessions { private readonly channel: SessionProvideChannel /** 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. @@ -392,6 +392,17 @@ export class TestSessions implements ISessions { 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 { + this.calls.push({ method: 'fork', args: [opts] }) + return Promise.resolve(opts.sessionId) + } + /** * The session face of a fixture (typed view for assertions; fixture * behavior methods are grafted onto it). diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c6b597ae79..ca0159ce3d 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -262,6 +262,13 @@ export function apply(ctx: Context): void { }) }, 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) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b9e1e3351f..68b9b71cb8 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -230,7 +230,7 @@ function StreamingTail({ useSession, onGrow }: { * The chat view slot entry: pure component over the composed props (tool rows * 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) // Workspace root off the session list row: path summaries display relative to it. 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. */ if (node.kind === 'tool-result') return null - return + return } return ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index fc76cfb753..124371f2da 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,5 +1,6 @@ -// Shared IconActions chrome for user and assistant messages: copy / branch -// live (branch still a stub), date-aware clock, optional edit stub. +// Shared IconActions chrome for user and assistant messages: copy live, +// branch wired through onBranch (stub without it), date-aware clock, +// optional edit stub. import { useCallback } from 'react' import { @@ -18,17 +19,19 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** When true, append the stub edit control (user bubble). */ 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. */ className?: string | undefined } /** * 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. */ export function MessageIconActions({ - text, time, clock, edit, className, + text, time, clock, edit, onBranch, className, }: MessageIconActionsProps) { const day = useCalendarDay() const onCopy = useCallback(() => { @@ -48,7 +51,7 @@ export function MessageIconActions({ - diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index aa56d35270..2eb8e313ae 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -16,6 +16,8 @@ import css from './MessageItem.module.css' export interface MessageItemProps { 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[] } { @@ -61,7 +63,7 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { +export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) { switch (node.kind) { case 'user': { const { text, rest } = contentText(node.content) @@ -76,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) time={node.time} clock="start" edit + onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} className={css.actions} /> diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1684e616b2..dca8cbc567 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -419,6 +419,8 @@ export interface ChatViewInjected { */ openFile: (path: string) => 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. */ diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index c0cb4dcb78..960f954d61 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -94,6 +94,7 @@ function makeHarness(init?: Partial) { const openDetails = vi.fn<(t: SelectionTarget) => void>() const openFile = vi.fn<(path: string) => void>() const loadOlder = vi.fn() + const forkAt = vi.fn() // Selection rides the REAL chat store (same construction path as // production; the view reads it through the PropsStore useStore share). // renderSlot stub renders the render-site fallback (an empty keyed ledger: @@ -120,9 +121,10 @@ function makeHarness(init?: Partial) { openDetails, openFile, loadOlder, + forkAt, } 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', () => { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index a1fa0683ac..035bfe06a7 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -83,7 +83,7 @@ interface DragState { type SessionTreeProps = Pick< WorkspaceBrowserProps, - 'useSessions' | 'startSession' | 'open' | 'insertSessionBefore' + 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' > & { workspaces: readonly WorkspaceView[] /** 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. */ function SessionTree({ - useSessions, startSession, open, workspaces, query, + useSessions, startSession, open, forkSession, workspaces, query, onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, }: SessionTreeProps) { const list = useSessions(s => s) @@ -115,6 +115,24 @@ function SessionTree({ if (current === undefined || currentGroup === undefined) return setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, 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( () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), [list, workspaces, expandedProjects, expandedSessions, query], @@ -195,6 +213,7 @@ function SessionTree({ now={now} onOpen={open} onRename={onSessionRename} + onFork={forkSession} onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }} drag={dragProps} /> @@ -209,7 +228,7 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, onSessionRename, query }: Pick) { +function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pick) { const list = useSessions(s => s) const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) const now = Date.now() @@ -228,6 +247,7 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick {}} flat @@ -254,6 +274,7 @@ export function WorkspaceBrowser({ startSession, open, renameSession, + forkSession, renameWorkspace, deleteWorkspace, insertSessionBefore, @@ -460,13 +481,14 @@ export function WorkspaceBrowser({ {/* Always-mounted seat keeps the region's flex slot while the list itself is wide-only. */} -
- {wide && (groupBy === 'flat' - ? +
+ {wide && (groupBy === 'flat' + ? : ( void /** Rename a Session (explicit user title; resolves on host acceptance). */ renameSession: (sessionId: SessionId, title: string) => Promise + /** 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). */ renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index c1f5e61ba4..f7ced8016a 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -59,6 +59,13 @@ export function apply(ctx: ClientContext): void { const result = await session.rename(title) 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) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index a507ac2991..c4b9752690 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -2,8 +2,8 @@ * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only - * except workspace Rename/Delete and session Rename; the session and workspace - * hover cards are suppressed while a menu is open. + * except workspace Rename/Delete and session Rename/Fork; the session and + * workspace hover cards are suppressed while a menu is open. */ import { useState } from 'react' 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' } -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 depth: number currentId: string | undefined @@ -192,6 +192,8 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onOpen: (id: SessionNode['id']) => void /** Open the browser-owned session rename dialog (row menu action). */ 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 /** Present only on draggable rows (workspace-group roots outside search). */ drag?: RowDragProps | undefined @@ -259,9 +261,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, open={menuOpen} onClose={() => { setMenuOpen(false) }} items={SESSION_MENU_ITEMS} - onSelect={(id) => { - setMenuOpen(false) - if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only. + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename(node.id, row.title) + if (id === 'fork') onFork(node.id) // delete stays visual-only. }} portal closeOnPointerLeave @@ -295,6 +298,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, now={now} onOpen={onOpen} onRename={onRename} + onFork={onFork} onToggle={onToggle} /> ))} diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index c4b440578a..dc8e9379dc 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -67,9 +67,9 @@ describe('workspace browser rows', () => { } const onOpen = vi.fn() const onToggle = vi.fn() - const view = render( - , + const view = render( + , ) const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')! @@ -88,9 +88,9 @@ describe('workspace browser rows', () => { view.rerender( , ) expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy() @@ -162,9 +162,9 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid('s1'), title: 'One', children: [], hasChildren: false, expanded: false, running: false, updatedAt: 0, - } - render() + } + render() fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) expect(onOpen).not.toHaveBeenCalled() expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/) @@ -189,9 +189,9 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid('p'), title: 'Parent', children: [], hasChildren: true, expanded: false, running: false, updatedAt: 0, - } - render() + } + render() expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull() }) @@ -201,9 +201,9 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, expanded: false, running: true, updatedAt: 0, - } - render() + } + render() const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -228,9 +228,9 @@ describe('workspace browser rows', () => { const node: SessionNode = { id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, expanded: false, running: false, updatedAt: 0, - } - render() + } + render() fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('Idle')).toBeTruthy() @@ -246,9 +246,9 @@ describe('workspace browser rows', () => { expanded: false, running: false, updatedAt: 0, } const inactive = dragProps() - const { rerender } = render( - , + const { rerender } = render( + , ) const row = screen.getByRole('treeitem') stubRect(row) @@ -264,9 +264,9 @@ describe('workspace browser rows', () => { expect(inactive.end).toHaveBeenCalledOnce() const active = dragProps({ active: true, marker: 'before' }) - rerender( - , + rerender( + , ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -278,9 +278,9 @@ describe('workspace browser rows', () => { expect(active.drop).toHaveBeenCalledWith('after') const after = dragProps({ active: true, marker: 'after' }) - rerender( - , + rerender( + , ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index abe896dffe..d0535ecdfa 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -56,6 +56,7 @@ function mount(overrides: Partial = {}) { startSession: vi.fn(), open: vi.fn(), renameSession: vi.fn(async () => {}), + forkSession: vi.fn(), renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index cab5747963..e3c73746a9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -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) { const { sessionId, mode, content } = request.payload const found = await agentFor(sessionId) diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index bc197be3c5..54c68e9c1c 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -24,6 +24,7 @@ export interface RpcMethodMap { 'session.models': SessionsApi['models'] 'session.selectModel': SessionsApi['selectModel'] 'session.rename': SessionsApi['rename'] + 'session.fork': SessionsApi['fork'] 'session.prompt': SessionsApi['prompt'] 'session.updateQueue': SessionsApi['updateQueue'] 'session.cancel': SessionsApi['cancel'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index cbd793ebd0..95701f6deb 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -51,6 +51,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', 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('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({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index c1fa4e1611..720db149c1 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,6 +51,7 @@ export interface RpcErrorDetailsMap { /** A leading-/ prompt named no registered command; the message names the token. */ 'unknown-command': {} 'title-invalid': { sessionId: SessionId } + 'fork-unavailable': { sessionId: SessionId } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index e3744ec37a..12bc273d98 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -89,6 +89,17 @@ export const sessionRenameValueSchema = z.object({ seq: z.number().int().nonnegative(), }) satisfies z.ZodType>> +/** 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>> + +/** session.fork response value (the child session id). */ +export const sessionForkValueSchema = z.object({ + sessionId: sessionIdSchema, +}) satisfies z.ZodType>> + /** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */ export const sessionHistoryRequestSchema = z.object({ sessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 077087a0de..70a4003a1a 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -238,6 +238,20 @@ export interface SessionsApi { * 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. */ + /** + * 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> + + /** 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[] }>): Promise> diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 38e684fa20..b1dc1e4a1e 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -20,6 +20,7 @@ import { import { sessionCancelValueSchema, sessionCreateValueSchema, + sessionForkValueSchema, sessionHistoryValueSchema, sessionListValueSchema, sessionModelsValueSchema, @@ -69,6 +70,7 @@ export interface IApiClient { models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise>> selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise>> + fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> @@ -121,6 +123,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.models', payload, signal), selectModel: (payload, signal) => this.callUnary('session.selectModel', 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), updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 98dac06b9f..785ce7431b 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' import { sessionCancelRequestSchema, sessionCreateRequestSchema, + sessionForkRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, sessionModelsRequestSchema, @@ -71,6 +72,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) }, 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(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.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 4751e3357b..2981c9ddea 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -47,6 +47,7 @@ function scriptedApi(overrides: { selected: { provider: r.payload.provider, model: r.payload.model }, }), rename: r => ok(r, { title: 'renamed', seq: 0 }), + fork: r => ok(r, { sessionId: sid('s-fork') }), prompt: r => ok(r, { accepted: true as const }), updateQueue: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index f356beac0d..bc76b7242f 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async rename(request) { 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) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } },