feat(web): session list one-list, hover card, row menus, rename, manual ordering
Sidebar session list grows the figma 239-10458 feature set and the
workspace/session browsing region moves wholesale into ui-workspace:
- Group-by menu (WorkSpace / In one list): flat mode lists every session
top-level, strictly newest-first; the choice persists across reloads.
- Session rows get a 500ms hover detail card (title / relative time /
status line) and a ... menu (Rename / Fork session / Delete session,
visual-only for now); workspace headers get ... with Rename (wired) and
Delete workspace (visual-only).
- workspace.rename RPC: trims, rejects duplicate titles on the create
chain (workspace-name-conflict), no-op on same title; modal dialog with
client-side duplicate pre-check.
- workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted
anchor appends): HTML5 drag reorder of root sessions inside a workspace
group; order truth stays host-side, the view refreshes from the
response/changed frame.
- Activity pinning removed: the session/event touchSession chain is gone;
workspace accounts are manually owned (new sessions prepend, explicit
reordering only). Contracts and tests updated, api catalog regenerated.
- ui-sidebar reduced to the column shell (brand, fold state machine, New
Session, Settings) exposing one sidebar.workspaces hole with a two-fact
owner share {wide, expandSidebar}; ui-workspace owns the whole region
(header, search, grouped/flat lists, dialogs, drag) plus the picker via
a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and
its deferral indirection are gone.
- ui-primitives: Menu gains label entries, danger rows, and
closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled
guard). Hover card and row menu never coexist.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/** Sidebar slot registration and its plain runtime/layout callbacks. */
|
||||
/** Sidebar shell slot registration and its plain runtime/layout callbacks. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -9,10 +9,8 @@ async function bench(declare = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const layout = { toggleSidebar: vi.fn() }
|
||||
const sessions = { open: vi.fn() }
|
||||
const workspaces = { startSession: vi.fn() }
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspaces as never)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
if (declare) {
|
||||
@@ -21,25 +19,23 @@ async function bench(declare = true) {
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
return { ctx, slots, layout, sessions, workspaces }
|
||||
return { ctx, slots, layout, workspaces }
|
||||
}
|
||||
|
||||
describe('ui-sidebar apply', () => {
|
||||
it('declares only the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
|
||||
expect(inject).toEqual(['slots', 'layout', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers the sidebar and declares its Workspace picker hole', async () => {
|
||||
it('registers the shell and declares the browsing-region hole', async () => {
|
||||
const b = await bench()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||
expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar'])
|
||||
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
|
||||
injected.startSession('workspace' as never, 'prompt')
|
||||
expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt')
|
||||
injected.open('session' as never)
|
||||
expect(b.sessions.open).toHaveBeenCalledWith('session')
|
||||
injected.toggleSidebar()
|
||||
expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => {
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.spec('sidebar.workspace')).toBeUndefined()
|
||||
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/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
|
||||
|
||||
describe('sidebar 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')
|
||||
})
|
||||
})
|
||||
@@ -1,79 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
const workspace: WorkspaceView = {
|
||||
workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')],
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [sid('s1')],
|
||||
byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } },
|
||||
current: undefined, phase: 'ready',
|
||||
intent: undefined,
|
||||
}
|
||||
const workspaces: WorkspaceListState = {
|
||||
items: [workspace], state: 'idle', phase: 'ready', error: null,
|
||||
intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId,
|
||||
}
|
||||
|
||||
function mount(sessionState: SessionListState = sessions) {
|
||||
const startSession = vi.fn()
|
||||
const open = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
const view = render(
|
||||
<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
|
||||
startSession={startSession} open={open} toggleSidebar={vi.fn()}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
||||
/>,
|
||||
)
|
||||
return { view, startSession, open, pickerOwner: () => pickerOwner }
|
||||
}
|
||||
// The shell never reads the global hooks itself, but they ride the standard
|
||||
// props share; stub them as never-called functions.
|
||||
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
|
||||
|
||||
function mountSidebar({
|
||||
sessionState = sessions,
|
||||
workspaceState = workspaces,
|
||||
collapsed = false,
|
||||
width = 300,
|
||||
}: {
|
||||
sessionState?: SessionListState
|
||||
workspaceState?: WorkspaceListState
|
||||
collapsed?: boolean
|
||||
width?: number
|
||||
} = {}) {
|
||||
function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
|
||||
const startSession = vi.fn()
|
||||
const open = vi.fn()
|
||||
const toggleSidebar = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
let current = { sessionState, workspaceState, collapsed, width }
|
||||
let regionOwner: SidebarSectionOwnerProps | undefined
|
||||
let current = { collapsed, width }
|
||||
const root = () => (
|
||||
<SidebarRoot
|
||||
collapsed={current.collapsed} width={current.width}
|
||||
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
|
||||
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
||||
useSessions={neverHook} useWorkspaces={neverHook}
|
||||
startSession={startSession} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => {
|
||||
regionOwner = owner
|
||||
return <div data-testid="region" data-wide={owner.wide} />
|
||||
}) as SidebarRootComponentProps['renderSlot']}
|
||||
/>
|
||||
)
|
||||
const view = render(root())
|
||||
return {
|
||||
startSession,
|
||||
open,
|
||||
toggleSidebar,
|
||||
pickerOwner: () => pickerOwner,
|
||||
regionOwner: () => {
|
||||
if (regionOwner === undefined) throw new Error('region owner not rendered')
|
||||
return regionOwner
|
||||
},
|
||||
rerender(next: Partial<typeof current>) {
|
||||
current = { ...current, ...next }
|
||||
view.rerender(root())
|
||||
@@ -81,181 +44,40 @@ function mountSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('Project')).toBeTruthy()
|
||||
describe('SidebarRoot shell', () => {
|
||||
it('routes New Session and the column toggle', () => {
|
||||
const b = mountShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('shows a frontend Session under its real Workspace and routes its row plus', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const }
|
||||
const b = mount({
|
||||
...sessions,
|
||||
current: intent.sessionId,
|
||||
intent,
|
||||
})
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
expect(screen.getByText('2 sessions')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
||||
})
|
||||
|
||||
it('forwards Workspace picker selection and closes the picker', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
owner.onPick(workspace.workspaceId)
|
||||
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
||||
})
|
||||
|
||||
it('opens a real Session through the owner action', () => {
|
||||
const b = mount({ ...sessions, current: sid('intent'), intent: {
|
||||
sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready',
|
||||
} })
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
fireEvent.click(screen.getByText('First session'))
|
||||
expect(b.open).toHaveBeenCalledWith(sid('s1'))
|
||||
})
|
||||
|
||||
it('opens, selects, dismisses, and toggles the group-by menu', () => {
|
||||
mount()
|
||||
const button = screen.getByRole('button', { name: 'Group by' })
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes every Workspace picker close path', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onClose(): void }
|
||||
expect(owner.open).toBe(true)
|
||||
act(() => { owner.onClose() })
|
||||
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
|
||||
})
|
||||
|
||||
it('focuses, filters, and clears search while distinguishing both empty states', () => {
|
||||
mount()
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
fireEvent.click(input.parentElement!)
|
||||
expect(document.activeElement).toBe(input)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
|
||||
fireEvent.change(input, { target: { value: 'missing' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.queryByText('No matches')).toBeNull()
|
||||
|
||||
cleanup()
|
||||
const emptySessions = listState()
|
||||
const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined }
|
||||
mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces })
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('toggles Workspace and nested Session expansion in both directions', () => {
|
||||
const parent = sid('parent')
|
||||
const child = sid('child')
|
||||
const nestedSessions: SessionListState = {
|
||||
...sessions,
|
||||
ids: [parent, child],
|
||||
byId: {
|
||||
[parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 },
|
||||
[child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent },
|
||||
},
|
||||
}
|
||||
const nestedWorkspace: WorkspaceListState = {
|
||||
...workspaces,
|
||||
items: [{ ...workspace, sessionIds: [parent, child] }],
|
||||
}
|
||||
mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace })
|
||||
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('Child')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('Child')).toBeNull()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(screen.queryByText('Parent')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not start a Session from an Ungrouped row create action', () => {
|
||||
const loose = sid('loose')
|
||||
const looseSessions: SessionListState = {
|
||||
...listState(),
|
||||
ids: [loose],
|
||||
byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } },
|
||||
current: loose,
|
||||
}
|
||||
const b = mountSidebar({
|
||||
sessionState: looseSessions,
|
||||
workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
|
||||
expect(b.startSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => {
|
||||
const b = mountSidebar()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] }
|
||||
b.rerender({
|
||||
sessionState: { ...sessions, current: sid('s1') },
|
||||
workspaceState: { ...workspaces, items: [other, workspace] },
|
||||
})
|
||||
expect(screen.getByText('First session')).toBeTruthy()
|
||||
|
||||
b.rerender({
|
||||
sessionState: {
|
||||
...sessions,
|
||||
current: sid('draft'),
|
||||
intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' },
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Project')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the static collapsed rail and expands rail search into focused input', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountSidebar({ collapsed: true })
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledTimes(2)
|
||||
b.rerender({ collapsed: false })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('keeps wide content during live collapse, then settles to the rail', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountSidebar({ width: 320 })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
b.rerender({ collapsed: true, width: 56 })
|
||||
expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
|
||||
const b = mountShell()
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
// Expanded: the request is a no-op (no accidental collapse).
|
||||
b.regionOwner().expandSidebar()
|
||||
expect(b.toggleSidebar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the region mounted through collapse and expands on its request', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountShell()
|
||||
b.rerender({ collapsed: true })
|
||||
// Wide content survives the crossfade window, then settles into the rail.
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
vi.advanceTimersByTime(200)
|
||||
b.rerender({})
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByTestId('region')).toBeTruthy()
|
||||
b.regionOwner().expandSidebar()
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders statically collapsed on a cold start (no crossfade classes)', () => {
|
||||
const b = mountShell({ collapsed: true })
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
function listState(): SessionListState {
|
||||
return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined }
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.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('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('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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user