feat: fork

This commit is contained in:
imccyu
2026-07-29 03:03:43 +08:00
parent 5fc2f4ba04
commit 57fb5b488e
29 changed files with 330 additions and 49 deletions

View File

@@ -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)

View File

@@ -46,6 +46,7 @@ export class FakeApiClient implements IApiClient {
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 }))
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 })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => 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)),

View File

@@ -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<SessionId>
/**
* Register a per-session standard-props provider (hooks become `use<Name>`
* selector hooks on the render side; props spread verbatim).

View File

@@ -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
* existing entry only gains fields it lacks (the session-added frame and the

View File

@@ -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<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).
* @param id - session id (the agent identity — 1:1 same axis).

View File

@@ -64,6 +64,7 @@ export class FakeApiClient implements IApiClient {
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
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 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => 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)),

View File

@@ -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<SessionId> {
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).

View File

@@ -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)

View File

@@ -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 <MessageItem key={item.key} node={node} />
return <MessageItem key={item.key} node={node} onFork={forkAt} />
}
return (

View File

@@ -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({
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
<IconBranchOutline16 />
</button>
</Tooltip>

View File

@@ -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}
/>
</div>

View File

@@ -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. */

View File

@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
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<ConversationSnapshot>) {
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', () => {

View File

@@ -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<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 rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
@@ -228,6 +247,7 @@ function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTre
now={now}
onOpen={open}
onRename={onSessionRename}
onFork={forkSession}
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
onToggle={() => {}}
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. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
forkSession={forkSession}
workspaces={workspaces}
startSession={startSession}
open={open}

View File

@@ -95,6 +95,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
open: (sessionId: SessionId) => void
/** Rename a Session (explicit user title; resolves on host acceptance). */
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). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */

View File

@@ -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) => {

View File

@@ -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}
/>
))}

View File

@@ -67,9 +67,9 @@ describe('workspace browser rows', () => {
}
const onOpen = vi.fn()
const onToggle = vi.fn()
const view = render(
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle} />,
const view = render(
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle} />,
)
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
@@ -88,9 +88,9 @@ describe('workspace browser rows', () => {
view.rerender(
<SessionNodeItem
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle}
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen}
onRename={vi.fn()} onFork={vi.fn()} onToggle={onToggle}
/>,
)
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(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onToggle={vi.fn()} />)
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={vi.fn()} onToggle={vi.fn()} />)
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(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} flat />)
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} flat />)
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(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} />)
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
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(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} />)
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} />)
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(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
const { rerender } = render(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
)
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(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={active} />,
)
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(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onToggle={vi.fn()} drag={after} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})

View File

@@ -56,6 +56,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
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 () => {}),

View File

@@ -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)

View File

@@ -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']

View File

@@ -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('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<RpcError>

View File

@@ -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': {}
}

View File

@@ -89,6 +89,17 @@ export const sessionRenameValueSchema = z.object({
seq: z.number().int().nonnegative(),
}) 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). */
export const sessionHistoryRequestSchema = z.object({
sessionId: sessionIdSchema,

View File

@@ -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<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[] }>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>

View File

@@ -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<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
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'>>>
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
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.selectModel': sessionSelectModelValueSchema,
'session.rename': sessionRenameValueSchema,
'session.fork': sessionForkValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
@@ -334,6 +337,7 @@ export abstract class AbstractApiClient implements IApiClient {
models: (payload, signal) => 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),

View File

@@ -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) },

View File

@@ -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 }),

View File

@@ -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 } } }
},