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
@@ -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}
@@ -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. */
@@ -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) => {
@@ -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}
/>
))}