Merge remote-tracking branch 'origin/master' into mergebot/pr711

# Conflicts:
#	apps/cli/README.i18n.yaml
#	apps/cli/README.md
#	apps/cli/README.zh.md
#	apps/cli/cordis.yml
#	apps/cli/package.json
#	docs/config-catalog.md
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/src/client/contract/sessions.ts
#	packages/client/test-runtime/src/sessions.ts
#	packages/client/ui-workspace/README.i18n.yaml
#	packages/client/ui-workspace/README.md
#	packages/client/ui-workspace/README.zh.md
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
#	packages/client/ui-workspace/src/client/tree.ts
#	packages/client/ui-workspace/tests/apply.spec.ts
#	packages/client/ui-workspace/tests/tree.spec.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/tests/client-handler.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
imccyu
2026-07-31 01:28:15 +08:00
980 changed files with 30720 additions and 7127 deletions
@@ -23,20 +23,16 @@ async function bench() {
ok: true as const,
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
}))
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
const binding = vi.fn(() => ({ session: { rename: renameSession } }))
const fork = vi.fn(async () => 'forked' as never)
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20 } as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
return {
ctx,
slots: ctx.get('slots') as SlotsService,
create,
startSession,
rename,
insertSessionBefore,
open,
clear,
search,
ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename,
insertSessionBefore, open, clear, search, renameSession, binding, fork,
}
}
@@ -87,6 +83,14 @@ describe('ui-workspace apply', () => {
})
expect(b.search).toHaveBeenCalledWith('match', signal)
expect(browser.searchResultLimit).toBe(20)
await browser.renameSession('session' as never, 'renamed session')
expect(b.binding).toHaveBeenCalledWith('session')
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
browser.forkSession('session' as never)
await vi.waitFor(() => {
expect(b.open).toHaveBeenCalledWith('forked')
})
expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true })
await browser.renameWorkspace('ws' as never, 'renamed')
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
@@ -61,7 +61,7 @@ describe('workspace browser rows', () => {
const onToggle = vi.fn()
const onCreate = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
@@ -75,46 +75,22 @@ describe('workspace browser rows', () => {
expect(onToggle).toHaveBeenCalledOnce()
})
it('renders and operates selected, running, recursive Session nodes', () => {
const child: SessionNode = {
id: sid('child'), title: 'Child', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
const parent: SessionNode = {
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
expanded: true, running: true, updatedAt: 0,
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', running: true, updatedAt: 0,
}
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} />,
render(
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onFork={vi.fn()} />,
)
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
expect(parentRow.getAttribute('aria-selected')).toBe('true')
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
expect(childRow.getAttribute('aria-selected')).toBe('false')
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(onToggle).toHaveBeenCalledWith(parent.id)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(parentRow)
fireEvent.click(childRow)
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
view.rerender(
<SessionNodeItem
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen}
onRename={vi.fn()} onToggle={onToggle}
/>,
)
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
const row = screen.getByRole('treeitem')
expect(row.getAttribute('aria-selected')).toBe('true')
expect(row.hasAttribute('aria-expanded')).toBe(false)
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
fireEvent.click(row)
expect(onOpen).toHaveBeenCalledWith(node.id)
})
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
@@ -122,7 +98,7 @@ describe('workspace browser rows', () => {
const onDelete = vi.fn()
const onToggle = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem
@@ -147,24 +123,43 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('workspace hover card shows title, directory path, and creation time after the dwell', () => {
vi.useFakeTimers()
try {
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
// Card body: full title + cwd + absolute creation time.
expect(screen.getAllByText('Project')).toHaveLength(2)
expect(screen.getByText('/projects/project')).toBeTruthy()
expect(screen.getByText(/^Created /)).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('ungrouped bucket renders no workspace menu', () => {
const group: GroupNode = {
key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
})
it('session row menu opens without opening the session and dispatches rename', () => {
it('session row menu opens without opening the session and dispatches rename and fork', () => {
const onOpen = vi.fn()
const onRename = vi.fn()
const onFork = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'One', 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} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} />)
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
expect(onOpen).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
@@ -173,9 +168,10 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
expect(onOpen).not.toHaveBeenCalled()
// Fork and Delete stay visual-only.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
expect(onFork).toHaveBeenCalledWith(node.id)
// Delete stays visual-only.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
expect(onRename).toHaveBeenCalledOnce()
@@ -185,25 +181,14 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('flat variant renders no twist even for a parent and ignores toggling', () => {
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 />)
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
})
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
expanded: false, running: true, updatedAt: 0,
id: sid('s1'), title: 'Hovered', 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} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} />)
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
@@ -226,11 +211,10 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', 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} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Idle')).toBeTruthy()
@@ -242,13 +226,12 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', 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} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
@@ -265,8 +248,8 @@ describe('workspace browser rows', () => {
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} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={active} />,
)
stubRect(screen.getByRole('treeitem'))
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -279,8 +262,8 @@ describe('workspace browser rows', () => {
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} />,
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={after} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})
@@ -24,7 +24,7 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const view = (expandedProjects: readonly string[] = []) => ({
expandedProjects, expandedSessions: [] as string[],
expandedProjects,
})
describe('deriveGroups', () => {
@@ -62,7 +62,7 @@ describe('deriveGroups', () => {
expect(strayGroups.map(group => group.key)).toEqual(['first'])
})
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
const parent = summary('parent', 1)
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
const newChild = { ...summary('new-child', 20), parentId: parent.id }
@@ -75,15 +75,13 @@ describe('deriveGroups', () => {
const groups = deriveGroups(
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id] },
{ expandedProjects: [UNGROUPED_KEY] },
)
expect(groups).toHaveLength(1)
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
sid('orphan'), sid('self'), parent.id, sid('cycle-a'),
])
expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([
newChild.id, tieA.id, tieB.id, oldChild.id,
cycleB.id, cycleA.id, orphan.id, self.id, parent.id,
])
// Equal timestamps use ids as a deterministic tiebreak in either input order.
@@ -120,8 +118,6 @@ describe('deriveFlat', () => {
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA))
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
// Rows are branch-free: no children, no expansion.
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
})
it('tolerates ids whose summary has not landed yet', () => {
@@ -58,6 +58,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
searchResultLimit: 20,
renameSession: vi.fn(async () => {}),
forkSession: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
@@ -126,7 +127,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('alpha-s')).toBeNull()
})
it('unfolds a session subtree through the row twist', () => {
it('renders a fork child as a top-level row without a session twist', () => {
const parent = summary('parent-s', 2)
const child = { ...summary('child-s', 1), parentId: parent.id }
mount({
@@ -134,11 +135,9 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('child-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('child-s')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('child-s')).toBeNull()
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true')
})
it('auto-expands the selected session group and starts a session from the group ', () => {