test(web): cover workspace sidebar behavior
This commit is contained in:
@@ -349,9 +349,9 @@ async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
|
||||
|
||||
/**
|
||||
* Reveal the seeded rows: every seeded session is unattached, so they all sit
|
||||
* in the collapsed Ungrouped bucket. Converges on expanded rather than
|
||||
* clicking once — startup auto-selection can expand the bucket first, and a
|
||||
* second click would collapse it again. Hand-rolled polling because
|
||||
* in the collapsed Ungrouped bucket. Open the bucket, then use its transient
|
||||
* Show-more control because an open group intentionally renders only five
|
||||
* rows by default. Hand-rolled polling because
|
||||
* `expect.poll` is test-scoped and this runs in `beforeAll`.
|
||||
* @param page - the page under test.
|
||||
*/
|
||||
@@ -364,6 +364,12 @@ async function expandSeededSessions(page: Page): Promise<void> {
|
||||
if (await bucket.getAttribute('aria-expanded') !== 'true') {
|
||||
await page.getByText('Ungrouped', { exact: true }).click()
|
||||
}
|
||||
const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ })
|
||||
if (await bucket.getAttribute('aria-expanded') === 'true'
|
||||
&& await rows.count() <= SEED_COUNT / 2
|
||||
&& await showMore.count() > 0) {
|
||||
await showMore.click()
|
||||
}
|
||||
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -152,6 +152,9 @@ export class FakeApiClient implements IApiClient {
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
|
||||
insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({
|
||||
workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId],
|
||||
}))),
|
||||
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
|
||||
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
|
||||
}))),
|
||||
|
||||
@@ -174,6 +174,9 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
|
||||
() => Promise.resolve(ok({ deleted: true }))
|
||||
|
||||
onWorkspaceInsertBefore: (payload: unknown) => Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>> =
|
||||
() => Promise.resolve(ok({ workspaceIds: [] }))
|
||||
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
@@ -189,6 +192,8 @@ export class FakeApiClient implements IApiClient {
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
|
||||
insertBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
archiveSession: (payload: unknown) =>
|
||||
|
||||
@@ -11,7 +11,7 @@ function summary(
|
||||
running = false,
|
||||
): SessionSummary {
|
||||
return {
|
||||
id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
|
||||
id: sid(id), displayTitle: id, running, blank: false, createdAt: 0, updatedAt: 0,
|
||||
...(parentId === undefined ? {} : { parentId }),
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
@@ -17,7 +17,7 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0
|
||||
}
|
||||
|
||||
describe('WorkspaceManager', () => {
|
||||
it('replays changed frames over hydration and keeps established order on refresh', async () => {
|
||||
it('replays changed frames over hydration and adopts the durable order on refresh', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
@@ -36,7 +36,7 @@ describe('WorkspaceManager', () => {
|
||||
items: [workspace('old'), workspace('new')] as never[],
|
||||
}))
|
||||
await manager.refresh()
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['old', 'new'])
|
||||
})
|
||||
|
||||
it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
|
||||
@@ -77,6 +77,38 @@ describe('WorkspaceManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('one'), workspace('two'), workspace('three')] as never[],
|
||||
}))
|
||||
const manager = new WorkspaceManager(api)
|
||||
await manager.refresh()
|
||||
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
|
||||
api.onWorkspaceInsertBefore = () => gate.promise
|
||||
const pending = manager.insertBefore(wid('three'), wid('one'))
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'newer-order' as never,
|
||||
payload: {
|
||||
type: 'host/workspace-order-changed',
|
||||
workspaceIds: [wid('one'), wid('three'), wid('two')],
|
||||
},
|
||||
})
|
||||
gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
|
||||
await pending
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(err({
|
||||
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' },
|
||||
}))
|
||||
const rejected = manager.insertBefore(wid('three'))
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
|
||||
await expect(rejected).resolves.toMatchObject({ ok: false })
|
||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||
})
|
||||
|
||||
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
@@ -309,6 +341,72 @@ describe('WorkspacesService', () => {
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
|
||||
it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('one'), workspace('two')] as never[],
|
||||
}))
|
||||
await workspaces.refresh()
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(ok({
|
||||
workspaceIds: [wid('two'), wid('one')],
|
||||
}))
|
||||
await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined()
|
||||
expect(api.callsOf('workspace.insertBefore')).toEqual([{
|
||||
workspaceId: 'two', beforeWorkspaceId: 'one',
|
||||
}])
|
||||
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
|
||||
|
||||
api.onWorkspaceInsertBefore = () => Promise.resolve(err({
|
||||
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
|
||||
}))
|
||||
await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
|
||||
it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
workspace('current-home', [sid('current')]),
|
||||
workspace('recent-home', [sid('recent')]),
|
||||
] as never[],
|
||||
}))
|
||||
api.onList = () => Promise.resolve(ok({ items: [
|
||||
{ sessionId: sid('current'), updatedAt: 1, running: false, blank: false },
|
||||
{ sessionId: sid('recent'), updatedAt: 2, running: false, blank: false },
|
||||
] as never[] }))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
sessions.open(sid('current'))
|
||||
const unresolved = new Promise<SessionId>(() => {})
|
||||
const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved)
|
||||
|
||||
workspaces.startSession(wid('recent-home'))
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
|
||||
|
||||
workspaces.startSession()
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('current-home'))
|
||||
|
||||
sessions.clear()
|
||||
workspaces.startSession()
|
||||
await Promise.resolve()
|
||||
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
|
||||
|
||||
const emptyCtx = new Context()
|
||||
const emptyApi = new FakeApiClient()
|
||||
const emptySessions = new SessionsService(emptyCtx, emptyApi)
|
||||
const emptyWorkspaces = new WorkspacesService(emptyCtx, emptyApi, emptySessions)
|
||||
const clear = vi.spyOn(emptySessions, 'clear')
|
||||
emptyWorkspaces.startSession()
|
||||
expect(clear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -233,6 +233,7 @@ export class TestSessions implements ISessions {
|
||||
displayTitle: fixture.id,
|
||||
running: false,
|
||||
blank: false,
|
||||
createdAt: this.records.size + 1,
|
||||
updatedAt: this.records.size + 1,
|
||||
...fixture.summary,
|
||||
}
|
||||
|
||||
@@ -99,10 +99,10 @@ function mount(
|
||||
} = {},
|
||||
) {
|
||||
const root = sid('root')
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, createdAt: 1, updatedAt: 1 }
|
||||
const childRow = {
|
||||
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
|
||||
running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
running: false, blank: options.summaryBlank ?? false, createdAt: 2, updatedAt: 2,
|
||||
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
|
||||
}
|
||||
const listed = options.omitSummaryRow !== true
|
||||
|
||||
@@ -56,6 +56,7 @@ function props(
|
||||
displayTitle: 'worker',
|
||||
running: true,
|
||||
blank: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
@@ -83,6 +84,7 @@ function summary(id: SessionId, updatedAt: number): SessionSummary {
|
||||
displayTitle: id,
|
||||
running: false,
|
||||
blank: false,
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, createdAt: 1, updatedAt: 1 } },
|
||||
current: SID,
|
||||
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ function listStore() {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: {
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('chat row diff body', () => {
|
||||
describe('FileMutationRow diff card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
@@ -314,7 +314,7 @@ describe('DetailsPanel diff Output section', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('GenericToolCard read body', () => {
|
||||
describe('ReadRow keyed toolview', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd: '/w/app' } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
@@ -260,7 +260,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
|
||||
@@ -345,7 +345,7 @@ describe('chat row terminal body', () => {
|
||||
describe('BashRow terminal card', () => {
|
||||
const list = () => createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
@@ -451,7 +451,7 @@ describe('DetailsPanel Output section', () => {
|
||||
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
|
||||
: {
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
|
||||
const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8')
|
||||
|
||||
/**
|
||||
* Declarations of one selector rule, keyed by property with whitespace collapsed.
|
||||
@@ -15,21 +16,23 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.m
|
||||
* @param selector - one exact selector, including a leading dot for local classes.
|
||||
* @returns the rule's declarations, or undefined when no such rule exists.
|
||||
*/
|
||||
function declarations(selector: string): Map<string, string> | undefined {
|
||||
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
function declarationsFrom(source: string, selector: string): Map<string, string> | undefined {
|
||||
const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
const found = new Map<string, string>()
|
||||
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
|
||||
const found = new Map<string, string>()
|
||||
for (const part of body.split(';')) {
|
||||
const colon = part.indexOf(':')
|
||||
if (colon === -1) continue
|
||||
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
|
||||
}
|
||||
return found
|
||||
}
|
||||
return undefined
|
||||
return found.size === 0 ? undefined : found
|
||||
}
|
||||
|
||||
const declarations = (selector: string): Map<string, string> | undefined => declarationsFrom(css, selector)
|
||||
const rowDeclarations = (selector: string): Map<string, string> | undefined => declarationsFrom(rowsCss, selector)
|
||||
|
||||
describe('WorkspaceBrowser.module.css list', () => {
|
||||
const root = declarations('.root')
|
||||
const listArea = declarations('.listArea')
|
||||
@@ -68,4 +71,15 @@ describe('WorkspaceBrowser.module.css list', () => {
|
||||
expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px')
|
||||
expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px')
|
||||
})
|
||||
|
||||
it('keeps the compact fade, overflow control, search field, and row heights', () => {
|
||||
expect(declarations('.fade')?.get('height')).toBe('24px')
|
||||
expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px')
|
||||
expect(declarations('.searchExpanded')?.get('height')).toBe('34px')
|
||||
expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px')
|
||||
expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px')
|
||||
expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px')
|
||||
expect(rowDeclarations('.sessionRow.selected')?.get('background'))
|
||||
.toBe('var(--dsw-alias-interactive-bg-hover)')
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ function installClipboard(writeText: (text: string) => Promise<void>): () => voi
|
||||
}
|
||||
}
|
||||
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() }
|
||||
|
||||
/** 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 {
|
||||
@@ -105,7 +105,6 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} t={t} />)
|
||||
|
||||
expect(screen.getByText('1 个会话')).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
@@ -117,7 +116,7 @@ describe('workspace browser rows', () => {
|
||||
it('renders and opens a selected running Session row', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('session'), title: 'Session', blank: false, running: true,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
render(
|
||||
@@ -138,7 +137,7 @@ describe('workspace browser rows', () => {
|
||||
<SessionNodeItem
|
||||
node={{
|
||||
id: sid('s1'), title: 'One', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0, ...over,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, ...over,
|
||||
}}
|
||||
currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
|
||||
@@ -170,7 +169,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('owner'), title: 'Delegating', blank: false, running: false,
|
||||
runningSubagentCount: 2, completed: false, updatedAt: 0,
|
||||
runningSubagentCount: 2, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -192,7 +191,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('owner'), title: 'Delegating', blank: false, running: true,
|
||||
runningSubagentCount: 1, completed: false, updatedAt: 0,
|
||||
runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -213,7 +212,7 @@ describe('workspace browser rows', () => {
|
||||
it('keeps child activity as a secondary status while user attention is primary', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question',
|
||||
running: false, runningSubagentCount: 1, completed: false, updatedAt: 0,
|
||||
running: false, runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -304,7 +303,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s-blank'), title: 'ignored', blank: true, running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -331,7 +330,7 @@ describe('workspace browser rows', () => {
|
||||
const onArchive = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
|
||||
@@ -365,7 +364,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Hovered', blank: false, running: true,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -396,7 +395,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid(pendingInteraction), title: 'Needs input', blank: false,
|
||||
pendingInteraction, running: true, runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
pendingInteraction, running: true, runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -423,7 +422,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Quiet', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -441,7 +440,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Done', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: true, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: true, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -457,7 +456,7 @@ describe('workspace browser rows', () => {
|
||||
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Drag me', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -11,7 +11,8 @@ 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, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
id: sid(id), displayTitle: id, running: false, blank: false,
|
||||
createdAt: updatedAt, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
@@ -377,11 +378,22 @@ describe('deriveSearchResults', () => {
|
||||
})
|
||||
|
||||
describe('createWorkspaceViewStore', () => {
|
||||
it('defaults to workspace grouping; setGroupBy is the sole mutation', () => {
|
||||
it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
expect(store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(store.getSnapshot().orderBy).toBe('manual')
|
||||
store.actions.setGroupBy('flat')
|
||||
store.actions.setOrderBy('updated')
|
||||
store.actions.setWorkspaceExpanded('alpha', true)
|
||||
store.actions.syncRecentSessions('alpha', ['two', 'one'], { one: 1, two: 2 })
|
||||
store.actions.setRecentSessionOrder('alpha', ['one', 'two'])
|
||||
expect(store.getSnapshot().groupBy).toBe('flat')
|
||||
expect(store.getSnapshot()).toMatchObject({
|
||||
orderBy: 'updated',
|
||||
workspaceExpansion: { alpha: true },
|
||||
recentSessionOrder: { alpha: ['one', 'two'] },
|
||||
recentSessionUpdatedAt: { alpha: { one: 1, two: 2 } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ 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,
|
||||
id: sid(id), displayTitle: id, running: false, blank: false, createdAt: updatedAt, updatedAt, ...overrides,
|
||||
})
|
||||
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
@@ -53,6 +53,10 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number):
|
||||
fireEvent(row, event)
|
||||
}
|
||||
|
||||
function dragData(): Pick<DataTransfer, 'effectAllowed' | 'dropEffect' | 'setData'> {
|
||||
return { effectAllowed: 'uninitialized', dropEffect: 'none', setData: vi.fn() }
|
||||
}
|
||||
|
||||
function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
const props: WorkspaceBrowserProps = {
|
||||
@@ -71,6 +75,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
archiveSession: vi.fn(async () => {}),
|
||||
insertWorkspaceBefore: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||
@@ -102,6 +107,8 @@ describe('WorkspaceBrowser', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
|
||||
expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy()
|
||||
expect(screen.queryByText('创建时间')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
|
||||
expect(b.store.getSnapshot().groupBy).toBe('flat')
|
||||
@@ -138,6 +145,61 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows five sessions by default and clears transient show-all when the Workspace collapses', () => {
|
||||
const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index))
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState(items)),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])),
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
for (const item of items.slice(0, 5)) expect(screen.getByText(item.displayTitle)).toBeTruthy()
|
||||
expect(screen.queryByText('session-6')).toBeNull()
|
||||
expect(screen.queryByText('session-7')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' }))
|
||||
expect(screen.getByText('session-6')).toBeTruthy()
|
||||
expect(screen.getByText('session-7')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: false })
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true })
|
||||
expect(screen.queryByText('session-6')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps recent-update order editable and promotes a Session when its timestamp advances', async () => {
|
||||
const initial = sessionState([summary('one', 3), summary('two', 2)])
|
||||
const b = mount({
|
||||
useSessions: hook(initial),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
|
||||
await waitFor(() => {
|
||||
const rows = screen.getAllByRole('treeitem').slice(1)
|
||||
expect(rows[0]?.textContent).toContain('one')
|
||||
expect(rows[1]?.textContent).toContain('two')
|
||||
})
|
||||
|
||||
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: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
||||
fireDrag(two, 'drop', 180)
|
||||
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
|
||||
|
||||
const updated = sessionState([summary('one', 4), summary('two', 2)])
|
||||
rerender(b, { useSessions: hook(updated) })
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['one', 'two'])
|
||||
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one')
|
||||
})
|
||||
})
|
||||
|
||||
it('archives a session from the row menu and hides archived rows in both modes', async () => {
|
||||
const archiveSession = vi.fn(async () => {})
|
||||
const b = mount({
|
||||
@@ -150,10 +212,9 @@ describe('WorkspaceBrowser', () => {
|
||||
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.
|
||||
// The archive-set echo hides the row in grouped and flat modes.
|
||||
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()
|
||||
@@ -253,7 +314,6 @@ describe('WorkspaceBrowser', () => {
|
||||
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)
|
||||
@@ -279,6 +339,7 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
const resultTree = screen.getByRole('tree', { name: '搜索结果' })
|
||||
@@ -302,6 +363,22 @@ describe('WorkspaceBrowser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('collapses an empty search on outside click but keeps a non-empty query expanded', () => {
|
||||
mount()
|
||||
const search = screen.getByRole('button', { name: '搜索会话' })
|
||||
fireEvent.click(search)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(document.body)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(search)
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'kept' } })
|
||||
fireEvent.click(document.body)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(input.value).toBe('kept')
|
||||
})
|
||||
|
||||
it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -524,6 +601,34 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.getByText('alpha')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses the full expanded Workspace section when resolving a Workspace drop half', () => {
|
||||
const insertWorkspaceBefore = vi.fn(async () => {})
|
||||
const sessions = sessionState(Array.from({ length: 5 }, (_, index) => summary(`beta-${index}`, index)))
|
||||
mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([
|
||||
workspace('alpha', []),
|
||||
workspace('beta', sessions.ids),
|
||||
workspace('tail', []),
|
||||
])),
|
||||
insertWorkspaceBefore,
|
||||
})
|
||||
fireEvent.click(screen.getByText('beta'))
|
||||
const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement
|
||||
let targetSection = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement
|
||||
while (targetSection.parentElement?.getAttribute('role') !== 'tree') {
|
||||
targetSection = targetSection.parentElement as HTMLElement
|
||||
}
|
||||
targetSection.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 300, left: 0, right: 200, width: 200, height: 200, x: 0, y: 100, toJSON: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(source, { dataTransfer: dragData() })
|
||||
// y=190 is below the header row but still in the top half of the whole
|
||||
// expanded section, so the target is before beta rather than after it.
|
||||
fireDrag(targetSection, 'drop', 190)
|
||||
expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
|
||||
})
|
||||
|
||||
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)])
|
||||
@@ -538,7 +643,7 @@ describe('WorkspaceBrowser', () => {
|
||||
three.getBoundingClientRect = () => ({
|
||||
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
const dataTransfer = dragData()
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
// Drop on the top half of "three": insert one before three.
|
||||
fireDrag(three, 'dragOver', 205)
|
||||
@@ -569,7 +674,7 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
|
||||
fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } })
|
||||
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
||||
// 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'])])) })
|
||||
@@ -594,7 +699,7 @@ describe('WorkspaceBrowser', () => {
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
const dataTransfer = dragData()
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireEvent.dragEnd(one)
|
||||
// The drag ended: rows no longer accept drops.
|
||||
@@ -608,6 +713,28 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
|
||||
})
|
||||
|
||||
it('accepts a document-level drop and commits the last Session marker on drag end', () => {
|
||||
const insertSessionBefore = vi.fn(async () => {})
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])),
|
||||
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: () => ({}),
|
||||
})
|
||||
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
||||
fireDrag(two, 'dragOver', 180)
|
||||
const outsideDrop = createEvent.drop(document.body)
|
||||
Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() })
|
||||
fireEvent(document.body, outsideDrop)
|
||||
expect(outsideDrop.defaultPrevented).toBe(true)
|
||||
fireEvent.dragEnd(one)
|
||||
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 {
|
||||
@@ -623,7 +750,7 @@ describe('WorkspaceBrowser', () => {
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
const dataTransfer = dragData()
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'drop', 180)
|
||||
await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
|
||||
|
||||
@@ -308,6 +308,48 @@ describe('workspace.create', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace.insertBefore', () => {
|
||||
it('commits the complete order, streams one order frame, and maps unknown ids', async () => {
|
||||
const { api, root } = await harness()
|
||||
const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace
|
||||
const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace
|
||||
const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
||||
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
||||
const changed = nextHostFrame(stream)
|
||||
const reordered = expectOk(await api.workspace.insertBefore(request({
|
||||
workspaceId: first.workspaceId,
|
||||
beforeWorkspaceId: second.workspaceId,
|
||||
})))
|
||||
expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId])
|
||||
expect(await changed).toMatchObject({
|
||||
payload: {
|
||||
type: 'host/workspace-order-changed',
|
||||
workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId],
|
||||
},
|
||||
})
|
||||
expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId))
|
||||
.toEqual(reordered.workspaceIds)
|
||||
|
||||
const missingSource = await api.workspace.insertBefore(request({
|
||||
workspaceId: 'missing' as WorkspaceId,
|
||||
}))
|
||||
expect(missingSource.result).toMatchObject({
|
||||
ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } },
|
||||
})
|
||||
const missingAnchor = await api.workspace.insertBefore(request({
|
||||
workspaceId: first.workspaceId,
|
||||
beforeWorkspaceId: 'missing-anchor' as WorkspaceId,
|
||||
}))
|
||||
expect(missingAnchor.result).toMatchObject({
|
||||
ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } },
|
||||
})
|
||||
abort.abort()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session creation and Workspace membership', () => {
|
||||
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
|
||||
const { api, ctx, root } = await harness()
|
||||
|
||||
@@ -84,6 +84,7 @@ function scriptedApi(overrides: {
|
||||
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
|
||||
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
delete: r => ok(r, { deleted: true as const }),
|
||||
insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }),
|
||||
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
|
||||
archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }),
|
||||
},
|
||||
|
||||
@@ -174,6 +174,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async delete(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } }
|
||||
},
|
||||
async insertBefore(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } }
|
||||
},
|
||||
async insertSessionBefore(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema,
|
||||
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
|
||||
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
|
||||
workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListRequestSchema, workspaceListValueSchema,
|
||||
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
|
||||
@@ -351,6 +352,17 @@ describe('workspace domain schemas', () => {
|
||||
expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow()
|
||||
})
|
||||
|
||||
it('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => {
|
||||
expect(workspaceInsertBeforeRequestSchema.parse({
|
||||
workspaceId: 'w1', beforeWorkspaceId: 'w2',
|
||||
}).beforeWorkspaceId).toBe('w2')
|
||||
expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId)
|
||||
.toBeUndefined()
|
||||
expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow()
|
||||
expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds)
|
||||
.toEqual(['w2', 'w1'])
|
||||
})
|
||||
|
||||
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
|
||||
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
|
||||
|
||||
@@ -10,7 +10,11 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts'
|
||||
import WorkspaceRegistry, {
|
||||
WorkspaceId,
|
||||
WorkspaceMoveInvalidError,
|
||||
WorkspaceOrderInvalidError,
|
||||
} from '../src/index.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
|
||||
|
||||
const DOMAIN_VERSION = 2
|
||||
@@ -568,6 +572,49 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace registry ordering', () => {
|
||||
it('moves a workspace before an anchor or to the end and restores that order after restart', async () => {
|
||||
const firstDir = await makeDir('order-first')
|
||||
const secondDir = await makeDir('order-second')
|
||||
const thirdDir = await makeDir('order-third')
|
||||
const result = await harness()
|
||||
const first = await result.registry.create(firstDir)
|
||||
const second = await result.registry.create(secondDir)
|
||||
const third = await result.registry.create(thirdDir)
|
||||
expect(result.registry.list().map(item => item.id)).toEqual([third.id, second.id, first.id])
|
||||
|
||||
await expect(result.registry.insertBefore(first.id, second.id))
|
||||
.resolves.toEqual([third.id, first.id, second.id])
|
||||
await expect(result.registry.insertBefore(third.id))
|
||||
.resolves.toEqual([first.id, second.id, third.id])
|
||||
expect(storedState(result.pool).workspaceIds).toEqual([first.id, second.id, third.id])
|
||||
|
||||
const restarted = await harness({ pool: result.pool })
|
||||
expect(restarted.registry.list().map(item => item.id)).toEqual([first.id, second.id, third.id])
|
||||
})
|
||||
|
||||
it('keeps self-anchored and already-positioned moves write-free and rejects unknown ids', async () => {
|
||||
const firstDir = await makeDir('order-noop-first')
|
||||
const secondDir = await makeDir('order-noop-second')
|
||||
const result = await harness()
|
||||
const first = await result.registry.create(firstDir)
|
||||
const second = await result.registry.create(secondDir)
|
||||
const written = result.changes.length
|
||||
|
||||
await result.registry.insertBefore(second.id, second.id)
|
||||
await result.registry.insertBefore(second.id, first.id)
|
||||
await result.registry.insertBefore(first.id)
|
||||
expect(result.changes).toHaveLength(written)
|
||||
expect(result.registry.list().map(item => item.id)).toEqual([second.id, first.id])
|
||||
|
||||
await expect(result.registry.insertBefore(WorkspaceId('missing')))
|
||||
.rejects.toBeInstanceOf(WorkspaceOrderInvalidError)
|
||||
await expect(result.registry.insertBefore(second.id, WorkspaceId('missing-anchor')))
|
||||
.rejects.toMatchObject({ workspaceId: 'missing-anchor' })
|
||||
expect(result.changes).toHaveLength(written)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace session ordering', () => {
|
||||
it('prepends new attaches and keeps repeat attach idempotent', async () => {
|
||||
const dir = await makeDir('attach-order')
|
||||
|
||||
Reference in New Issue
Block a user