Files
deepseek-harness/packages/client/ui-workspace/tests/workspace-browser.spec.tsx
T
Yichen Jiang eab0aeb9db feat(web): list background tasks in the session header
The task registry has run every background bash, pwsh, pty-send, and
one-shot subagent since it landed, but only the model could read it: a
human at the Web client could not see that a build was running, tell a
finished task from a stuck one, or find its outcome anywhere but the
`run_in_background` tool card that printed an id and never updated.

Task state now reaches the browser as one whole-snapshot `session/tasks`
mux frame per session, pushed at every registry commit that changes what
that session can see. `TaskService` gains `onTasksChanged`, which is
owner-granular because owner-disposal removal is a change no per-task
record can express. The carrier reads the exact owner the listener hands
it, so a push stays correct while that scope tears down, and reads the
baseline through the non-resuming `ctx.agents.get` so listing never
revives a cold session. The client keeps a last-wins mirror on
`SessionListState`, and a new `dsh-client-ui-task` package renders it
beside the subagent catalog — rendering nothing at all until the session
has a task, so an ordinary conversation grows no new chrome.

Streamed per-task output and human-initiated cancellation are separate
phases; the note records why neither has to undo this channel, and why
no Web path may call the consuming `ctx.tasks.read()`.
2026-08-08 23:29:41 +08:00

786 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
// The seat's key domain is workspace common; the stub mirrors the real
// lookup chain (namespace, then common vocabulary, then the key).
const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh)
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, blank: 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',
subagentsByParent: {}, tasksBySession: {},
currentAddress: 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[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({
items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return 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(),
searchSessions: vi.fn(async () => ({ items: [], hasMore: false })),
searchResultLimit: 20,
renameSession: vi.fn(async () => {}),
forkSession: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
archiveSession: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
t,
...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('工作区')).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: '分组方式' }))
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
expect(b.store.getSnapshot().groupBy).toBe('flat')
expect(screen.getByText('会话')).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: '分组方式' }))
fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' }))
expect(b.store.getSnapshot().groupBy).toBe('workspace')
expect(screen.getByText('工作区')).toBeTruthy()
// Escape closes the menu without picking.
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
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('archives a session from the row menu and hides archived rows in both modes', async () => {
const archiveSession = vi.fn(async () => {})
const b = mount({
useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])),
archiveSession,
})
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
expect(archiveSession).toHaveBeenCalledWith(sid('gone-s'))
// The archive-set echo hides the row in grouped mode (count included) and flat mode.
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) })
expect(screen.queryByText('gone-s')).toBeNull()
expect(screen.getByText('1 个会话')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
expect(screen.getByText('kept-s')).toBeTruthy()
expect(screen.queryByText('gone-s')).toBeNull()
})
it('logs and keeps the tree when the archive call rejects', async () => {
const rejection = new Error('archive exploded')
const archiveSession = vi.fn(async () => { throw rejection })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
mount({
useSessions: hook(sessionState([summary('alpha-s', 1)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
archiveSession,
})
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
await Promise.resolve()
await Promise.resolve()
expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection)
expect(screen.getByText('alpha-s')).toBeTruthy()
} finally {
warn.mockRestore()
}
})
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({
useSessions: hook(sessionState([parent, child])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.getByText('child-s')).toBeTruthy()
expect(screen.queryByRole('button', { name: /展开|收起/ })).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 ', () => {
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: '在“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: '工作区“未分组”的操作' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '在“未分组”中新建会话' }))
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('shows only the current blank session as the localized New Session, excluded from search', () => {
const currentBlank = summary('alpha-blank', 9, { blank: true })
const staleBlank = summary('beta-blank', 8, { blank: true })
const sessions = sessionState(
[currentBlank, staleBlank],
{ current: currentBlank.id },
)
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([
workspace('alpha', ['alpha-blank']), workspace('beta', ['beta-blank']),
])),
})
expect(screen.getByText('新会话')).toBeTruthy()
expect(screen.queryByText('alpha-blank')).toBeNull()
expect(screen.queryByText('beta-blank')).toBeNull()
expect(screen.getByText('1 个会话')).toBeTruthy()
rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) })
expect(screen.getAllByText('新会话')).toHaveLength(1)
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getAllByText('新会话')).toHaveLength(1)
// Search excludes blank rows entirely — neither the canonical stored
// title nor the localized display label participates in matching.
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'new session' } })
expect(screen.queryByText('新会话')).toBeNull()
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: '新会话' } })
expect(screen.queryByText('新会话')).toBeNull()
})
it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
vi.useFakeTimers()
try {
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>('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'needle' } })
const resultTree = screen.getByRole('tree', { name: '搜索结果' })
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
const status = screen.getByRole('status')
expect(status.textContent).toBe('正在搜索会话历史…')
expect(resultTree.contains(status)).toBe(false)
fireEvent.change(input, { target: { value: 'zzz' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('无匹配会话')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(input.value).toBe('')
expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy()
// Clicking the field row focuses the input (wide mode).
fireEvent.click(input.parentElement as HTMLElement)
expect(document.activeElement).toBe(input)
} finally {
vi.useRealTimers()
}
})
it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
vi.useFakeTimers()
try {
const open = vi.fn()
const searchSessions = vi.fn(async () => ({
items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }],
hasMore: true,
}))
mount({
useSessions: hook(sessionState([
summary('body-hit', 1, { displayTitle: 'Research notes' }),
])),
useWorkspaces: hook(workspaceState([
workspace('research', ['body-hit'], 'Research Workspace'),
])),
open,
searchSessions,
})
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'waterfall token' } })
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
expect(screen.queryByText('Research notes')).toBeNull()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(searchSessions).toHaveBeenCalledWith('waterfall token', expect.any(AbortSignal))
expect(screen.getByText('Research notes')).toBeTruthy()
expect(screen.getByText('Research Workspace')).toBeTruthy()
expect(screen.getByText('…the waterfall token appears here…')).toBeTruthy()
expect(screen.getByText('仅显示前 20 条结果,请缩小搜索范围。')).toBeTruthy()
fireEvent.click(screen.getByRole('treeitem'))
expect(open).toHaveBeenCalledWith(sid('body-hit'))
expect(input.value).toBe('waterfall token')
} finally {
vi.useRealTimers()
}
})
it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => {
vi.useFakeTimers()
try {
const searchSessions = vi.fn(async () => ({ items: [], hasMore: false }))
mount({ searchSessions })
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
expect(input.maxLength).toBe(500)
fireEvent.change(input, { target: { value: 'y'.repeat(501) } })
expect(input.value).toBe('y'.repeat(500))
const expected = `prefix${'x'.repeat(493)}`
fireEvent.change(input, {
target: { value: `prefix\0${'x'.repeat(493)}😀tail` },
})
expect(input.value).toBe(expected)
expect(input.value.length).toBe(499)
expect(input.value).not.toContain('\0')
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(searchSessions).toHaveBeenCalledOnce()
expect(searchSessions).toHaveBeenCalledWith(expected, expect.any(AbortSignal))
} finally {
vi.useRealTimers()
}
})
it('keeps local matches and shows a lightweight warning when Host search fails', async () => {
vi.useFakeTimers()
try {
const searchSessions = vi.fn(async () => { throw new Error('index unavailable') })
mount({
useSessions: hook(sessionState([
summary('local-hit', 1, { displayTitle: 'Needle title' }),
])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
searchSessions,
})
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), {
target: { value: 'needle' },
})
expect(screen.getByText('Needle title')).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('Needle title')).toBeTruthy()
expect(screen.getByText('内容搜索暂不可用,仅显示名称匹配。')).toBeTruthy()
expect(screen.queryByText('无匹配会话')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('aborts a superseded request and ignores its stale result', async () => {
vi.useFakeTimers()
try {
let resolveFirst!: (value: {
items: { sessionId: SessionId; snippet: string }[]
hasMore: boolean
}) => void
const first = new Promise<{
items: { sessionId: SessionId; snippet: string }[]
hasMore: boolean
}>((resolve) => { resolveFirst = resolve })
const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
? first
: Promise.resolve({
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
hasMore: false,
}))
mount({
useSessions: hook(sessionState([
summary('first-hit', 2, { displayTitle: 'Old result' }),
summary('second-hit', 1, { displayTitle: 'Fresh result' }),
])),
searchSessions,
})
const input = screen.getByPlaceholderText('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'first' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
expect(firstSignal.aborted).toBe(false)
fireEvent.change(input, { target: { value: 'second' } })
expect(firstSignal.aborted).toBe(true)
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('Fresh result')).toBeTruthy()
await act(async () => {
resolveFirst({
items: [{ sessionId: sid('first-hit'), snippet: 'stale excerpt' }],
hasMore: false,
})
await Promise.resolve()
})
expect(screen.queryByText('Old result')).toBeNull()
expect(screen.getByText('Fresh result')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('ignores a rejected request after it has been superseded', async () => {
vi.useFakeTimers()
try {
let rejectFirst!: (reason: Error) => void
const first = new Promise<never>((_resolve, reject) => { rejectFirst = reject })
const searchSessions = vi.fn((query: string) => query === 'first'
? first
: Promise.resolve({ items: [], hasMore: false }))
mount({ searchSessions })
const input = screen.getByPlaceholderText('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'first' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
fireEvent.change(input, { target: { value: 'second' } })
await act(async () => {
rejectFirst(new Error('stale failure'))
await Promise.resolve()
})
expect(screen.queryByText('内容搜索暂不可用,仅显示名称匹配。')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('shows the no-sessions empty state in both modes and resolves an empty search', async () => {
vi.useFakeTimers()
try {
const b = mount()
expect(screen.getByText('暂无会话')).toBeTruthy()
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('暂无会话')).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('无匹配会话')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
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('工作区')).toBeNull()
expect(screen.queryByPlaceholderText('搜索名称、关键词…')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
// The wide flip mounts the input and focuses it after the slide.
rerender(b, { wide: true })
const input = screen.getByPlaceholderText('搜索名称、关键词…')
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: '搜索会话' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('rail add-workspace raises the directory flow in place, with no menu and no expansion', () => {
const expandSidebar = vi.fn()
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
expect(expandSidebar).not.toHaveBeenCalled()
// Adding is the header's only action, so the gesture IS that action: no
// one-row popover, and existing workspaces stay in the tree below.
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
})
it('hides the add button when no directory-flow occupant is composed', () => {
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => false, subscribe: () => () => {} }),
})
// Nothing to add with, so the header offers no dead button.
expect(screen.queryByRole('button', { name: '添加工作区' })).toBeNull()
expect(screen.getByText('alpha')).toBeTruthy()
})
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: () => ({}),
})
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: () => ({}),
})
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: () => ({}),
})
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: () => ({}),
})
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: () => ({}),
})
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: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
const input = screen.getByLabelText<HTMLInputElement>('工作区名称')
expect(input.value).toBe('Alpha')
// Unchanged and blank names stay blocked.
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: ' ' } })
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).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('已存在名为“Beta”的工作区。')
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: 'Gamma' } })
fireEvent.click(screen.getByRole('button', { name: '重命名' }))
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: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
const input = screen.getByLabelText<HTMLInputElement>('工作区名称')
// 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: '取消' }))
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: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
fireEvent.change(screen.getByLabelText('工作区名称'), { target: { value: 'Other' } })
fireEvent.click(screen.getByRole('button', { name: '重命名' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
})
it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => {
let resolveDelete!: () => void
const deleteWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveDelete = resolve }))
const browser = mount({
useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
deleteWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
const dialog = screen.getByRole('dialog', { name: '删除工作区' })
expect(dialog.textContent).toContain('将把“Alpha”从工作区列表中移除')
expect(dialog.textContent).toContain('文件夹与会话记录会保留')
expect(dialog.textContent).toContain('其会话将显示在“未分组”下')
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: '删除工作区' })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(deleteWorkspace).toHaveBeenCalledOnce()
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
expect(confirm.disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '取消' }).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('正在删除工作区…')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: '关闭' }))
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
await act(async () => { resolveDelete() })
// RPC success alone does not close: the component waits until its
// useWorkspaces projection has committed the removal, preventing a stale
// Workspace frame from leaking into the next gesture.
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
})
it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
const deleteWorkspace = vi.fn()
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockRejectedValueOnce('denied')
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
deleteWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
fireEvent.click(screen.getByRole('button', { name: '删除工作区' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '删除工作区' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
})
it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
const deleteWorkspace = vi.fn(async () => {})
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
deleteWorkspace,
})
const open = () => {
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
}
open()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
open()
fireEvent.keyDown(document, { key: 'Escape' })
open()
fireEvent.click(screen.getByRole('button', { name: '关闭' }))
expect(deleteWorkspace).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
})
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('搜索名称、关键词…'), { target: { value: 'needle' } })
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
expect(row.hasAttribute('draggable')).toBe(false)
})
})