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:
@@ -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 +', () => {
|
||||
|
||||
Reference in New Issue
Block a user