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:
@@ -10,7 +10,6 @@ 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 { WorkspaceEntity } from '../src/entity.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
|
||||
|
||||
@@ -434,13 +433,12 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
})
|
||||
|
||||
describe('Workspace session ordering', () => {
|
||||
it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => {
|
||||
it('prepends new attaches and keeps repeat attach idempotent', async () => {
|
||||
const dir = await makeDir('attach-order')
|
||||
const result = await harness()
|
||||
result.setSessions([
|
||||
header('s1', dir, 1),
|
||||
header('s2', dir, 2),
|
||||
header('ungrouped', dir, 3),
|
||||
])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
@@ -448,59 +446,7 @@ describe('Workspace session ordering', () => {
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
|
||||
const beforeTouch = result.changes.length
|
||||
await Promise.all([
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
])
|
||||
expect(workspace.sessionIds).toEqual(['s1', 's2'])
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
await result.registry.touchSession(SessionId('s1'))
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
await result.registry.touchSession(SessionId('ungrouped'))
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2'])
|
||||
})
|
||||
|
||||
it('does not resurrect a session detached before its queued touch', async () => {
|
||||
const dir = await makeDir('detach-touch-race')
|
||||
const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] })
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.attachSession(SessionId('s2'))
|
||||
await Promise.all([
|
||||
workspace.detachSession(SessionId('s1')),
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
])
|
||||
const written = result.changes.length
|
||||
await workspace.detachSession(SessionId('absent'))
|
||||
expect(result.changes).toHaveLength(written)
|
||||
expect(workspace.sessionIds).toEqual(['s2'])
|
||||
})
|
||||
|
||||
it('does not reinsert a candidate absent at the durable touch slot', async () => {
|
||||
const dir = await makeDir('stale-touch')
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000030')
|
||||
let durable = record(dir, ['s2', 's1'])
|
||||
const table = {
|
||||
update: async (
|
||||
_id: WorkspaceId,
|
||||
update: (current: WorkspaceRecord) => WorkspaceRecord,
|
||||
): Promise<WorkspaceRecord> => {
|
||||
durable = { ...durable, sessionIds: [SessionId('s2')] }
|
||||
durable = update(durable)
|
||||
return durable
|
||||
},
|
||||
}
|
||||
const entity = new WorkspaceEntity({
|
||||
table: () => table as never,
|
||||
sessionPath: () => dir,
|
||||
readSessionHeader: async () => header('s1', dir),
|
||||
rememberSessionPath: () => {},
|
||||
}, id, record(dir, ['s2', 's1']))
|
||||
await entity.touchSession(SessionId('s1'))
|
||||
expect(durable.sessionIds).toEqual(['s2'])
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1'])
|
||||
})
|
||||
|
||||
it('validates a lazy live session without requiring it in persistence.list()', async () => {
|
||||
@@ -546,66 +492,6 @@ describe('Workspace session ordering', () => {
|
||||
expect(workspace.sessionIds).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('keeps workspace order stable while touch order survives reload', async () => {
|
||||
const older = await makeDir('stable-older')
|
||||
const newer = await makeDir('stable-newer')
|
||||
const sessions = [
|
||||
header('old-1', older, 100),
|
||||
header('old-2', older, 200),
|
||||
header('new-1', newer, 300),
|
||||
]
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({ pool, sessions })
|
||||
const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id)
|
||||
const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)!
|
||||
expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1'])
|
||||
await first.registry.touchSession(SessionId('old-1'))
|
||||
expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2'])
|
||||
expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
|
||||
await first.fiber.dispose()
|
||||
|
||||
const reloaded = await harness({ pool, sessions })
|
||||
expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
|
||||
expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds)
|
||||
.toEqual(['old-1', 'old-2'])
|
||||
})
|
||||
|
||||
it('persists activity order from session/event without any stream consumer', async () => {
|
||||
const dir = await makeDir('event-touch')
|
||||
const result = await harness({ sessionStore: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } })
|
||||
result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } })
|
||||
await workspace.attachSession(SessionId('event-first'))
|
||||
await workspace.attachSession(SessionId('event-second'))
|
||||
expect(workspace.sessionIds).toEqual(['event-second', 'event-first'])
|
||||
|
||||
first.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) })
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds)
|
||||
.toEqual(['event-first', 'event-second'])
|
||||
})
|
||||
|
||||
it('contains a background activity write failure at the service listener', async () => {
|
||||
const dir = await makeDir('event-touch-failure')
|
||||
const result = await harness({ sessionStore: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } })
|
||||
result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } })
|
||||
await workspace.attachSession(SessionId('failed-first'))
|
||||
await workspace.attachSession(SessionId('failed-second'))
|
||||
const warn = vi.spyOn(result.ctx.logger, 'warn')
|
||||
result.pool.failNextWrites = 1
|
||||
first.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) })
|
||||
expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('header-validated membership projection', () => {
|
||||
|
||||
Reference in New Issue
Block a user