Merge branch 'code-mode-ui/live-parallel' into code-mode-ui/dispatch-spill
Conflict resolution: scripts/type-equiv.manifest.json takes master's new paired-derivative format (one primary entry per pair) and re-adds this stack's CodeDispatchLog entry in that format. zh README pairs brought along for the dispatch-log arm (spill-policy behavior/limitations bullets, tools limitation bullet now pointing at the shipped bounding).
This commit is contained in:
@@ -2,7 +2,8 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
async function bench() {
|
||||
@@ -13,32 +14,33 @@ async function bench() {
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
ctx.provide('workspaces', { create })
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create }
|
||||
const startSession = vi.fn()
|
||||
const rename = vi.fn(async () => ({}))
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never)
|
||||
ctx.provide('sessions', { open } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open }
|
||||
}
|
||||
|
||||
function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace'
|
||||
|
||||
function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected {
|
||||
const entry = slots.entries(name)[0]!
|
||||
return (entry.inject as () => WorkspacePickerInjected)()
|
||||
/** Declare one or both holes with a single root registration ('root' is a single slot). */
|
||||
function declare(slots: SlotsService, ...names: HoleName[]): () => void {
|
||||
const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }]))
|
||||
return slots.register({ name: 'root', children } as never, () => null)
|
||||
}
|
||||
|
||||
describe('ui-workspace apply', () => {
|
||||
it('declares the independent Workspace service', () => {
|
||||
expect(inject).toEqual(['slots', 'workspaces'])
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers the shared picker for declarations that arrive before or after apply', async () => {
|
||||
it('registers browser and picker for declarations arriving before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots, 'sidebar.workspace')
|
||||
declare(before.slots, 'sidebar.workspaces')
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser)
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -47,23 +49,35 @@ describe('ui-workspace apply', () => {
|
||||
expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
})
|
||||
|
||||
it('routes name and path creation to WorkspacesService', async () => {
|
||||
it('routes browser actions and picker creation to the services', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspace')
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots, 'sidebar.workspace')
|
||||
await injected.createWorkspace({ name: 'project' })
|
||||
await injected.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' })
|
||||
expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' })
|
||||
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
browser.startSession('ws', 'prompt')
|
||||
expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt')
|
||||
browser.open('session')
|
||||
expect(b.open).toHaveBeenCalledWith('session')
|
||||
await browser.renameWorkspace('ws', 'renamed')
|
||||
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
|
||||
await browser.insertSessionBefore('ws', 's1', 's2')
|
||||
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
|
||||
await browser.createWorkspace({ name: 'project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||
|
||||
const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
await picker.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
})
|
||||
|
||||
it('unregisters picker entries on teardown', async () => {
|
||||
it('unregisters both entries on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspace')
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar.workspace')).toHaveLength(0)
|
||||
expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
251
packages/client/ui-workspace/tests/rows.spec.tsx
Normal file
251
packages/client/ui-workspace/tests/rows.spec.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
|
||||
/** Half detection reads the row rect; jsdom rects are all-zero by default. */
|
||||
function stubRect(row: HTMLElement): void {
|
||||
row.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
|
||||
x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
}
|
||||
|
||||
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
|
||||
return {
|
||||
start: vi.fn(), active: false, marker: null,
|
||||
hover: vi.fn(), drop: vi.fn(), end: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
|
||||
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
||||
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
||||
const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
|
||||
Object.defineProperty(event, 'clientY', { value: clientY })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
|
||||
fireEvent(row, event)
|
||||
}
|
||||
|
||||
describe('workspace browser rows', () => {
|
||||
it('renders an active Workspace and keeps its create action separate from toggling', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
|
||||
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the frontend Intent placeholder as selected', () => {
|
||||
render(<IntentRowItem />)
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
|
||||
)
|
||||
|
||||
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} 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')
|
||||
})
|
||||
|
||||
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
|
||||
const onRename = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
// Opening the menu neither toggles the group nor renames yet.
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// Delete stays visual-only: selecting it just closes the menu.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('ungrouped bucket renders no workspace menu', () => {
|
||||
const group: GroupNode = {
|
||||
key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: 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 closes on selection', () => {
|
||||
const onOpen = vi.fn()
|
||||
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} 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/)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
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()} 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,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
// Card body: full title + relative time + running status.
|
||||
expect(screen.getAllByText('Hovered')).toHaveLength(2)
|
||||
expect(screen.getByText('1min ago')).toBeTruthy()
|
||||
expect(screen.getByText('Running')).toBeTruthy()
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// Menu open (disabled=true) suppresses the card for the same hover.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' }))
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('1min ago')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('idle hover card shows the Idle status line', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
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()} onToggle={vi.fn()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
expect(screen.getByText('now ago')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
expect(row.getAttribute('draggable')).toBe('true')
|
||||
fireEvent.dragStart(row, { dataTransfer })
|
||||
expect(inactive.start).toHaveBeenCalledOnce()
|
||||
// Inactive drag: hover and drop are rejected.
|
||||
fireEvent.dragOver(row, { dataTransfer })
|
||||
fireEvent.drop(row, { dataTransfer })
|
||||
expect(inactive.hover).not.toHaveBeenCalled()
|
||||
expect(inactive.drop).not.toHaveBeenCalled()
|
||||
fireEvent.dragEnd(row)
|
||||
expect(inactive.end).toHaveBeenCalledOnce()
|
||||
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
|
||||
expect(active.hover).toHaveBeenCalledWith('before')
|
||||
fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
|
||||
expect(active.hover).toHaveBeenCalledWith('after')
|
||||
fireDrag(screen.getByRole('treeitem'), 'drop', 130)
|
||||
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()} onToggle={vi.fn()} drag={after} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
})
|
||||
198
packages/client/ui-workspace/tests/tree.spec.ts
Normal file
198
packages/client/ui-workspace/tests/tree.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
intent: undefined,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title: id,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const view = (expandedProjects: readonly string[] = [], query = '') => ({
|
||||
expandedProjects, expandedSessions: [] as string[], query,
|
||||
})
|
||||
|
||||
describe('deriveGroups', () => {
|
||||
it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
|
||||
const sessions = list(summary('newer', 20), summary('older', 10))
|
||||
const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
|
||||
const groups = deriveGroups(sessions, workspaces, view(['first']))
|
||||
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
|
||||
})
|
||||
|
||||
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
|
||||
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
|
||||
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
|
||||
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
|
||||
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
|
||||
})
|
||||
|
||||
it('shows one frontend Session row only under a real target Workspace', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
|
||||
const target = workspace('first', [])
|
||||
expect(deriveGroups({ ...list(), current: intent.sessionId, intent }, [target], view())[0]).toEqual(expect.objectContaining({
|
||||
intentHere: true,
|
||||
sessionCount: 1,
|
||||
containsCurrent: true,
|
||||
}))
|
||||
const hiddenIntent = { sessionId: sid('zero'), target: { kind: 'workspace-intent' as const }, prompt: '', phase: 'ready' as const }
|
||||
expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false)
|
||||
})
|
||||
|
||||
it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
|
||||
const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view())
|
||||
expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false }))
|
||||
})
|
||||
|
||||
it('search filters real Sessions and omits the Intent placeholder', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const }
|
||||
const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match'))
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('match')])
|
||||
expect(groups[0]!.intentHere).toBe(false)
|
||||
expect(groups[0]!.sessionCount).toBe(2)
|
||||
})
|
||||
|
||||
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
|
||||
const parent = summary('parent', 1)
|
||||
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
|
||||
const newChild = { ...summary('new-child', 20), parentId: parent.id }
|
||||
const tieB = { ...summary('tie-b', 20), parentId: parent.id }
|
||||
const tieA = { ...summary('tie-a', 20), parentId: parent.id }
|
||||
const self = { ...summary('self', 2), parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 3), parentId: sid('missing') }
|
||||
const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') }
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
|
||||
)
|
||||
|
||||
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,
|
||||
])
|
||||
|
||||
// Equal timestamps use ids as a deterministic tiebreak in either input order.
|
||||
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
|
||||
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
|
||||
})
|
||||
|
||||
it('tolerates Workspace membership arriving before its Session summary', () => {
|
||||
const partial: SessionListState = {
|
||||
...list(),
|
||||
ids: [sid('present')],
|
||||
byId: { [sid('present')]: summary('present', 1) },
|
||||
}
|
||||
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
|
||||
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
|
||||
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
|
||||
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
|
||||
const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
|
||||
const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
|
||||
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
|
||||
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
|
||||
|
||||
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
|
||||
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
|
||||
])
|
||||
|
||||
const labelOnly = deriveGroups(
|
||||
list(summary('hidden', 1)),
|
||||
[workspace('label-hit', ['hidden']), workspace('other', [])],
|
||||
view([], 'label'),
|
||||
)
|
||||
expect(labelOnly).toEqual([
|
||||
expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
|
||||
const owned = summary('owned', 1)
|
||||
const loose = summary('loose', 2)
|
||||
const ws = workspace('project', ['owned'])
|
||||
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
|
||||
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
|
||||
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
|
||||
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveFlat', () => {
|
||||
it('flattens every session — fork children included — newest-first with id tiebreak', () => {
|
||||
const parent = summary('parent', 10)
|
||||
const child = { ...summary('child', 30), parentId: parent.id }
|
||||
const tieB = summary('tie-b', 20)
|
||||
const tieA = summary('tie-a', 20)
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
|
||||
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('search filters by case-insensitive display-title substring', () => {
|
||||
const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
|
||||
const miss = { ...summary('miss', 1), displayTitle: 'Other' }
|
||||
expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
|
||||
})
|
||||
|
||||
it('tolerates ids whose summary has not landed yet', () => {
|
||||
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
|
||||
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspaceViewStore', () => {
|
||||
it('defaults to workspace grouping; setGroupBy is the sole mutation', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
expect(store.getSnapshot().groupBy).toBe('workspace')
|
||||
store.actions.setGroupBy('flat')
|
||||
expect(store.getSnapshot().groupBy).toBe('flat')
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectLabel', () => {
|
||||
it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
|
||||
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
|
||||
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
|
||||
expect(projectLabel('/projects/demo/')).toBe('demo')
|
||||
expect(projectLabel('C:\\projects\\demo\\')).toBe('demo')
|
||||
expect(projectLabel('/')).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('formats current, minute, hour, day, month, and year buckets', () => {
|
||||
const now = 400 * 24 * 60 * 60 * 1_000
|
||||
expect(formatRelativeTime(now, now)).toBe('now')
|
||||
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5min')
|
||||
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe('3h')
|
||||
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2d')
|
||||
expect(formatRelativeTime(now - 60 * 86_400_000, now)).toBe('2mo')
|
||||
expect(formatRelativeTime(0, now)).toBe('1y')
|
||||
})
|
||||
})
|
||||
458
packages/client/ui-workspace/tests/workspace-browser.spec.tsx
Normal file
458
packages/client/ui-workspace/tests/workspace-browser.spec.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides,
|
||||
})
|
||||
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
intent: undefined,
|
||||
...overrides,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
|
||||
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
||||
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
||||
const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
|
||||
Object.defineProperty(event, 'clientY', { value: clientY })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } })
|
||||
fireEvent(row, event)
|
||||
}
|
||||
|
||||
function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
const props: WorkspaceBrowserProps = {
|
||||
wide: true,
|
||||
expandSidebar: vi.fn(),
|
||||
useSessions: hook(sessionState([])),
|
||||
useWorkspaces: hook(workspaceState([])),
|
||||
useStore: bindSnapshotSelector(store),
|
||||
actions: store.actions,
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
return { view, props, store }
|
||||
}
|
||||
|
||||
/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */
|
||||
function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrowserProps>) {
|
||||
Object.assign(b.props, overrides)
|
||||
b.view.rerender(<WorkspaceBrowser {...b.props} />)
|
||||
}
|
||||
|
||||
describe('WorkspaceBrowser', () => {
|
||||
it('renders the grouped tree by default and switches to the flat list via Group by', () => {
|
||||
const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])),
|
||||
})
|
||||
expect(screen.getByText('Workspaces')).toBeTruthy()
|
||||
expect(screen.getByText('alpha')).toBeTruthy()
|
||||
// Sessions hidden while their group is folded.
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' }))
|
||||
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
|
||||
expect(b.store.getSnapshot().groupBy).toBe('flat')
|
||||
expect(screen.getByText('Sessions')).toBeTruthy()
|
||||
expect(screen.queryByText('alpha')).toBeNull()
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
expect(screen.getByText('beta-s')).toBeTruthy()
|
||||
|
||||
// Back to workspace grouping through the same menu.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' }))
|
||||
expect(b.store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(screen.getByText('Workspaces')).toBeTruthy()
|
||||
|
||||
// Escape closes the menu without picking.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(b.store.getSnapshot().groupBy).toBe('workspace')
|
||||
})
|
||||
|
||||
it('expands a group on click and opens a session row', () => {
|
||||
const open = vi.fn()
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('alpha-s', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
|
||||
open,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByText('alpha-s'))
|
||||
expect(open).toHaveBeenCalledWith(sid('alpha-s'))
|
||||
// Collapse hides the row again.
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('unfolds a session subtree through the row twist', () => {
|
||||
const parent = summary('parent-s', 2)
|
||||
const child = { ...summary('child-s', 1), parentId: parent.id }
|
||||
mount({
|
||||
useSessions: hook(sessionState([parent, child])),
|
||||
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()
|
||||
})
|
||||
|
||||
it('auto-expands the selected session group and starts a session from the group +', () => {
|
||||
const startSession = vi.fn()
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
|
||||
startSession,
|
||||
})
|
||||
// The current-group effect expanded the owning group without a click.
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' }))
|
||||
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
|
||||
})
|
||||
|
||||
it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => {
|
||||
const startSession = vi.fn()
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
|
||||
startSession,
|
||||
})
|
||||
// The loose session's group is UNGROUPED_KEY: expanded by the effect.
|
||||
expect(screen.getByText('loose')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
|
||||
expect(startSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an already-expanded group when the selection moves within it', () => {
|
||||
const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') })
|
||||
const b = mount({
|
||||
useSessions: hook(first),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])),
|
||||
})
|
||||
expect(screen.getByText('a')).toBeTruthy()
|
||||
// Selection hop inside the same group: the effect re-runs and leaves the
|
||||
// expansion list unchanged (no duplicate key, group still open).
|
||||
rerender(b, { useSessions: hook({ ...first, current: sid('b') }) })
|
||||
expect(screen.getByText('b')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(screen.queryByText('b')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the intent placeholder in both modes', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const }
|
||||
const sessions = sessionState([], { intent, current: sid('intent') })
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
|
||||
})
|
||||
// Grouped: the current-group effect expands the target group.
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('searches across groups, clears via the clear button, and shows the empty states', () => {
|
||||
const sessions = sessionState([
|
||||
summary('needle-row', 2, { displayTitle: 'Needle row' }),
|
||||
summary('other-row', 1, { displayTitle: 'Other row' }),
|
||||
])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
// Search forces matches visible without expansion state.
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(input.value).toBe('')
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('shows the no-sessions empty state in both modes', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
// Flat search misses show No matches.
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rail state renders icon controls that request expansion', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const expandSidebar = vi.fn()
|
||||
const b = mount({ wide: false, expandSidebar })
|
||||
// No wide chrome in rail state.
|
||||
expect(screen.queryByText('Workspaces')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
// The wide flip mounts the input and focuses it after the slide.
|
||||
rerender(b, { wide: true })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
// Wide search button is decorative (tabIndex -1, no expand call).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
|
||||
const expandSidebar = vi.fn()
|
||||
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
rerender(b, { wide: true })
|
||||
// The picker menu is open (anchored on the +); picking starts a session.
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
|
||||
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// Wide toggle: open and close without expand requests.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Escape closes the picker through its own onClose.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header
|
||||
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
|
||||
three.getBoundingClientRect = () => ({
|
||||
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
// Drop on the top half of "three": insert one before three.
|
||||
fireDrag(three, 'dragOver', 205)
|
||||
fireDrag(three, 'drop', 205)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three'))
|
||||
|
||||
// Dropping right back onto its own position is a no-op — top half
|
||||
// (anchor = itself) and bottom half (anchor = the next root) alike.
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
one.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
fireDrag(one, 'dragOver', 105)
|
||||
fireDrag(one, 'drop', 105)
|
||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(one, 'drop', 130)
|
||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still sends the reorder when the dragged row left the group mid-drag', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
|
||||
fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } })
|
||||
// The host dropped "one" from the workspace account while the drag is in
|
||||
// flight: the source index is gone but the drop still resolves its anchor.
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) })
|
||||
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
fireDrag(two, 'drop', 155)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
|
||||
})
|
||||
|
||||
it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireEvent.dragEnd(one)
|
||||
// The drag ended: rows no longer accept drops.
|
||||
fireDrag(two, 'drop', 180)
|
||||
expect(insertSessionBefore).not.toHaveBeenCalled()
|
||||
|
||||
// Bottom half of the last row: append (anchor omitted).
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'dragOver', 180)
|
||||
fireDrag(two, 'drop', 180)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
|
||||
})
|
||||
|
||||
it('logs and keeps the order when the reorder call rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') })
|
||||
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
||||
insertSessionBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'drop', 180)
|
||||
await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('renames a workspace through the row menu dialog', async () => {
|
||||
let resolveRename!: () => void
|
||||
const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve }))
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
expect(input.value).toBe('Alpha')
|
||||
// Unchanged and blank names stay blocked.
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
// A duplicate of another workspace's title shows the inline conflict.
|
||||
fireEvent.change(input, { target: { value: ' Beta ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: 'Gamma' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
|
||||
// While renaming: input disabled, close blocked, Enter ignored.
|
||||
expect(input.disabled).toBe(true)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
await act(async () => { resolveRename() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('rename via Enter, failure surfaces the error, Cancel closes', async () => {
|
||||
const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') })
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
// Enter with a blocked draft (unchanged) does nothing.
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(renameWorkspace).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: 'Renamed' } })
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed')
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') })
|
||||
// The dialog stays for retry; typing clears the error; Cancel closes.
|
||||
fireEvent.change(input, { target: { value: 'Renamed2' } })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports non-Error rename failures as text', async () => {
|
||||
const renameWorkspace = vi.fn(async () => { throw 'denied' })
|
||||
mount({
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
})
|
||||
|
||||
it('search hides drag affordances (rows are not draggable during search)', () => {
|
||||
const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
|
||||
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
|
||||
expect(row.getAttribute('draggable')).toBe('false')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user