refactor(client): remove dead sidebar ordering surfaces
This commit is contained in:
@@ -1387,11 +1387,10 @@ interface FixtureWorld {
|
||||
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
||||
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const fixtureSessionsNow = Date.now()
|
||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||
{ sessionId: sid('fx-alpha'), createdAt: fixtureSessionsNow - 180_000, updatedAt: fixtureSessionsNow, running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), createdAt: fixtureSessionsNow - 120_000, updatedAt: fixtureSessionsNow - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-gamma'), createdAt: fixtureSessionsNow - 60_000, updatedAt: fixtureSessionsNow - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
||||
]
|
||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||
const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [
|
||||
@@ -2047,16 +2046,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return ok(request, { sessionId: requestedId })
|
||||
}
|
||||
}
|
||||
const createdAt = Date.now()
|
||||
const created: SessionSummary = {
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: true, cwd,
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId, createdAt, blank: true, cwd })
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
|
||||
}
|
||||
if (workspace !== undefined && options.failWorkspaceAttach) {
|
||||
emitSession()
|
||||
@@ -2123,16 +2121,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
}
|
||||
let cut = boundary.seq + 1
|
||||
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
|
||||
const createdAt = Date.now()
|
||||
const child: SessionSummary = {
|
||||
sessionId: sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: false,
|
||||
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
|
||||
parentSessionId: sessionId,
|
||||
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||
}
|
||||
logs.set(child.sessionId, log.slice(0, cut))
|
||||
sessions.push(child)
|
||||
emitHost({
|
||||
type: 'host/session-added', sessionId: child.sessionId, createdAt, blank: false,
|
||||
type: 'host/session-added', sessionId: child.sessionId, blank: false,
|
||||
parentSessionId: sessionId,
|
||||
...source.cwd === undefined ? {} : { cwd: source.cwd },
|
||||
})
|
||||
|
||||
@@ -267,9 +267,8 @@ describe('createFixtureApi', () => {
|
||||
expect(seen).toHaveLength(1)
|
||||
const added = seen[0]
|
||||
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
|
||||
expect(Number.isFinite(added.createdAt)).toBe(true)
|
||||
expect(added).toEqual({
|
||||
type: 'host/session-added', sessionId: createdId, createdAt: added.createdAt, blank: true, cwd: '/tmp/fixture',
|
||||
type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture',
|
||||
})
|
||||
const list = await api.sessions.list(req({}))
|
||||
if (!list.result.ok) throw new Error('list failed')
|
||||
@@ -707,9 +706,8 @@ describe('createFixtureApi', () => {
|
||||
// write pushes the fresh workspace snapshot after session-added.
|
||||
const added = seen[0]
|
||||
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
|
||||
expect(Number.isFinite(added.createdAt)).toBe(true)
|
||||
expect(added).toEqual({
|
||||
type: 'host/session-added', sessionId: id, createdAt: added.createdAt, blank: true, cwd: '/tmp/fixture',
|
||||
type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture',
|
||||
})
|
||||
expect(seen[1]).toMatchObject({
|
||||
type: 'host/workspace-changed',
|
||||
@@ -741,9 +739,8 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
const added = frames[1]
|
||||
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
|
||||
expect(Number.isFinite(added.createdAt)).toBe(true)
|
||||
expect(added).toEqual({
|
||||
type: 'host/session-added', sessionId: preallocated, createdAt: added.createdAt, blank: true,
|
||||
type: 'host/session-added', sessionId: preallocated, blank: true,
|
||||
cwd: made.result.value.workspace.path,
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface SessionsPortSummary {
|
||||
/** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
|
||||
blank: boolean
|
||||
cwd?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ export interface TitledSessionSummary extends SessionSummary {
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
title?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||
@@ -78,7 +77,6 @@ export function flattenLineage(
|
||||
const pendingInteraction = pendingInteractions?.get(s.sessionId)
|
||||
out.push({
|
||||
...s,
|
||||
createdAt: s.createdAt ?? s.updatedAt,
|
||||
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
|
||||
completed: completed?.has(s.sessionId) ?? false,
|
||||
depth,
|
||||
|
||||
@@ -541,9 +541,8 @@ export class SessionManager {
|
||||
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
|
||||
const { result } = await this.api.sessions.create(payload)
|
||||
if (result.ok) {
|
||||
const createdAt = Date.now()
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: result.value.sessionId, createdAt, updatedAt: createdAt, running: false, blank: true,
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
||||
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
||||
...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
|
||||
} })
|
||||
@@ -553,11 +552,9 @@ export class SessionManager {
|
||||
// so expose it immediately as Ungrouped while the caller keeps the
|
||||
// prompt buffer and decides whether to retry attachment.
|
||||
if (publishedSessionId !== undefined) {
|
||||
const createdAt = Date.now()
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: publishedSessionId,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
updatedAt: Date.now(),
|
||||
running: false,
|
||||
blank: true,
|
||||
} })
|
||||
@@ -591,9 +588,8 @@ export class SessionManager {
|
||||
? result.value.sessionId
|
||||
: workspaceAttachSessionId(result.error)
|
||||
if (childId !== undefined) {
|
||||
const createdAt = Date.now()
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: childId, createdAt, updatedAt: createdAt, running: false, blank: false,
|
||||
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
|
||||
parentSessionId: opts.sessionId,
|
||||
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
|
||||
} })
|
||||
@@ -620,9 +616,8 @@ export class SessionManager {
|
||||
* @param agentPreset - the preset id the host confirmed.
|
||||
*/
|
||||
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
|
||||
const createdAt = Date.now()
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, agentPreset,
|
||||
sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset,
|
||||
} })
|
||||
}
|
||||
|
||||
@@ -799,10 +794,8 @@ export class SessionManager {
|
||||
const frame = envelope.payload
|
||||
switch (frame.type) {
|
||||
case 'host/session-added': {
|
||||
const createdAt = frame.createdAt ?? Date.now()
|
||||
this.mergeSummary({
|
||||
sessionId: frame.sessionId, createdAt, updatedAt: createdAt,
|
||||
running: false, blank: frame.blank,
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
@@ -1055,8 +1048,7 @@ export class SessionManager {
|
||||
const items = fresh.map((entry) => {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.createdAt === entry.createdAt
|
||||
&& prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
|
||||
@@ -66,8 +66,6 @@ export interface SessionSummary {
|
||||
* selected blank entry.
|
||||
*/
|
||||
blank: boolean
|
||||
/** Durable session creation time. */
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
/** Current host-computed projection values retained by the object layer. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
@@ -669,7 +667,6 @@ export class SessionsService implements ISessions {
|
||||
running: entry.running,
|
||||
...(entry.completed ? { completed: true } : {}),
|
||||
blank: entry.blank,
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.pendingInteraction === undefined
|
||||
? {}
|
||||
@@ -703,7 +700,6 @@ export class SessionsService implements ISessions {
|
||||
origin: 'subagent',
|
||||
running: child.activity === 'running',
|
||||
blank: false,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}
|
||||
} else if (summary.displayTitle !== displayTitle) {
|
||||
|
||||
@@ -11,7 +11,7 @@ function summary(
|
||||
running = false,
|
||||
): SessionSummary {
|
||||
return {
|
||||
id: sid(id), displayTitle: id, running, blank: false, createdAt: 0, updatedAt: 0,
|
||||
id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
|
||||
...(parentId === undefined ? {} : { parentId }),
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
}
|
||||
|
||||
@@ -233,7 +233,6 @@ 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, createdAt: 1, updatedAt: 1 }
|
||||
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
|
||||
const childRow = {
|
||||
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
|
||||
running: false, blank: options.summaryBlank ?? false, createdAt: 2, updatedAt: 2,
|
||||
running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
|
||||
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
|
||||
}
|
||||
const listed = options.omitSummaryRow !== true
|
||||
|
||||
@@ -56,7 +56,6 @@ function props(
|
||||
displayTitle: 'worker',
|
||||
running: true,
|
||||
blank: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
@@ -84,7 +83,6 @@ 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, createdAt: 1, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, 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, createdAt: 0, updatedAt: 0 },
|
||||
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, 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, createdAt: 0, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, 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, createdAt: 0, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, 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, createdAt: 0, updatedAt: 0, cwd: '/w/app' } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, 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, createdAt: 0, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, 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, createdAt: 0, updatedAt: 0 } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, 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, createdAt: 0, updatedAt: 0, cwd } },
|
||||
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
|
||||
current: SID,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {}, tasksBySession: {},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: 24d73beba56f527eb57276accb2693255bad9020
|
||||
README.zh.md: 85a5ce9210dd084e6e6e0746eb19aac12d98d54d
|
||||
README.md: 359979fa26b0e12c7e28b929029fb7d4e9fca324
|
||||
README.zh.md: 2139ea3cf7ec3ae84aaa84e181d2f36a60c9ba60
|
||||
|
||||
@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
|
||||
|
||||
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus in-Workspace Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. View options combine grouping with one browser-persisted Session order: entering **Last updated** performs a complete recency sort and later user prompts or steers promote their Session once, while entering **Manual** preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags also update the Host Workspace account. Workspace drag order is Host-durable in either Session order mode.
|
||||
|
||||
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only an empty query, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和一份浏览器持久化的 Session 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序,手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
|
||||
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起空查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。
|
||||
|
||||
|
||||
@@ -38,19 +38,6 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.viewOptionLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.viewOptionCheck {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Section header: title, an inline search control, and the two trailing
|
||||
actions. Expanding search collapses the action cluster and takes its room. */
|
||||
.sectionHeader {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutline16, IconCloseFill14, IconPersonalizationOutline16,
|
||||
Button, IconCloseFill14, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
@@ -89,24 +89,19 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
||||
t: WorkspaceBrowserProps['t']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const optionLabel = (label: string, selected: boolean) => (
|
||||
<span className={css.viewOptionLabel}>
|
||||
<span>{label}</span>
|
||||
{selected && <IconCheckOutline16 className={css.viewOptionCheck} />}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={[
|
||||
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
|
||||
{ id: 'workspace', label: optionLabel(t('groupBy.workspace'), groupBy === 'workspace') },
|
||||
{ id: 'flat', label: optionLabel(t('groupBy.flat'), groupBy === 'flat') },
|
||||
{ id: 'workspace', label: t('groupBy.workspace') },
|
||||
{ id: 'flat', label: t('groupBy.flat') },
|
||||
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
|
||||
{ id: 'manual', label: optionLabel(t('orderBy.manual'), orderBy === 'manual'), disabled: groupBy !== 'workspace' },
|
||||
{ id: 'updated', label: optionLabel(t('orderBy.updated'), orderBy === 'updated') },
|
||||
{ id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' },
|
||||
{ id: 'updated', label: t('orderBy.updated') },
|
||||
]}
|
||||
selectedIds={[groupBy, orderBy]}
|
||||
onSelect={(id) => {
|
||||
if (id === 'workspace' || id === 'flat') onGroupPick(id)
|
||||
else if (id === 'manual' || id === 'updated') onOrderPick(id)
|
||||
@@ -280,7 +275,7 @@ function SessionTree({
|
||||
})
|
||||
}, [recentSessionOrder, workspaces])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'),
|
||||
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }),
|
||||
[list, orderedWorkspaces, archivedSessionIds, expandedProjects],
|
||||
)
|
||||
const now = Date.now()
|
||||
@@ -684,12 +679,12 @@ export function WorkspaceBrowser({
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
|
||||
searchInput.current?.blur()
|
||||
if (query !== '') return
|
||||
if (normalizedQuery !== '') return
|
||||
setSearchExpanded(false)
|
||||
}
|
||||
document.addEventListener('click', onClick)
|
||||
return () => { document.removeEventListener('click', onClick) }
|
||||
}, [query, wide, searchExpanded])
|
||||
}, [normalizedQuery, wide, searchExpanded])
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedQuery === '') {
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface SessionNode {
|
||||
runningSubagentCount: number
|
||||
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
|
||||
completed: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
@@ -108,10 +107,6 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
function sortSessions(sessions: SessionSummary[]): void {
|
||||
sessions.sort(byRecency)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordinary sessions are visible; among blank sessions, only the current one
|
||||
* is visible. Subagent children use their parent header catalog; archived
|
||||
@@ -141,10 +136,12 @@ function buildGroup(
|
||||
createdAt: number | undefined,
|
||||
label: string,
|
||||
members: readonly SessionSummary[],
|
||||
orderBy: SessionOrderBy,
|
||||
order: 'account' | 'recency',
|
||||
): Group {
|
||||
const sessions = [...members]
|
||||
if (orderBy !== 'manual') sortSessions(sessions)
|
||||
// Workspace order is the caller-selected sessionIds; only Ungrouped lacks
|
||||
// an account order and therefore falls back to recency.
|
||||
if (order === 'recency') sessions.sort(byRecency)
|
||||
return { key, workspaceId, cwd, createdAt, label, sessions }
|
||||
}
|
||||
|
||||
@@ -157,7 +154,6 @@ function groupByWorkspace(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
archived: ReadonlySet<SessionId>,
|
||||
orderBy: SessionOrderBy,
|
||||
): Group[] {
|
||||
const groups: Group[] = []
|
||||
const accounted = new Set<SessionId>()
|
||||
@@ -172,7 +168,7 @@ function groupByWorkspace(
|
||||
}
|
||||
groups.push(buildGroup(
|
||||
workspace.workspaceId, workspace.workspaceId, workspace.path,
|
||||
Date.parse(workspace.createdAt), workspace.title, members, orderBy,
|
||||
Date.parse(workspace.createdAt), workspace.title, members, 'account',
|
||||
))
|
||||
}
|
||||
const stray = list.ids
|
||||
@@ -180,10 +176,7 @@ function groupByWorkspace(
|
||||
.filter((s): s is SessionSummary =>
|
||||
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
|
||||
if (stray.length > 0) {
|
||||
groups.push(buildGroup(
|
||||
UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray,
|
||||
orderBy === 'manual' ? 'updated' : orderBy,
|
||||
))
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -199,7 +192,6 @@ function sessionNode(
|
||||
running: s.running,
|
||||
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
|
||||
completed: s.completed === true,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
|
||||
}
|
||||
@@ -217,7 +209,6 @@ function sessionNode(
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param archivedSessionIds - registry-global archive set.
|
||||
* @param view - local expansion arrays.
|
||||
* @param orderBy - local session ordering mode.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveGroups(
|
||||
@@ -225,7 +216,6 @@ export function deriveGroups(
|
||||
workspaces: readonly WorkspaceView[],
|
||||
archivedSessionIds: readonly SessionId[],
|
||||
view: TreeView,
|
||||
orderBy: SessionOrderBy = 'manual',
|
||||
): GroupNode[] {
|
||||
const archived = new Set(archivedSessionIds)
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
@@ -235,7 +225,7 @@ export function deriveGroups(
|
||||
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces, archived, orderBy)) {
|
||||
for (const g of groupByWorkspace(list, workspaces, archived)) {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
@@ -273,7 +263,7 @@ export function deriveFlat(
|
||||
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
|
||||
rows.push(s)
|
||||
}
|
||||
sortSessions(rows)
|
||||
rows.sort(byRecency)
|
||||
return rows.map(session => sessionNode(session, descendants))
|
||||
}
|
||||
|
||||
|
||||
@@ -116,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, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
render(
|
||||
@@ -137,7 +137,7 @@ describe('workspace browser rows', () => {
|
||||
<SessionNodeItem
|
||||
node={{
|
||||
id: sid('s1'), title: 'One', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, ...over,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0, ...over,
|
||||
}}
|
||||
currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
|
||||
@@ -169,7 +169,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('owner'), title: 'Delegating', blank: false, running: false,
|
||||
runningSubagentCount: 2, completed: false, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 2, completed: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -191,7 +191,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('owner'), title: 'Delegating', blank: false, running: true,
|
||||
runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 1, completed: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -212,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, createdAt: 0, updatedAt: 0,
|
||||
running: false, runningSubagentCount: 1, completed: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -303,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, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, 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} />)
|
||||
@@ -330,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, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
|
||||
@@ -364,7 +364,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Hovered', blank: false, running: true,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, 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} />)
|
||||
@@ -395,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, createdAt: 0, updatedAt: 0,
|
||||
pendingInteraction, running: true, runningSubagentCount: 0, completed: false, 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} />)
|
||||
@@ -422,7 +422,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Quiet', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -440,7 +440,7 @@ describe('workspace browser rows', () => {
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Done', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: true, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -456,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, createdAt: 0, updatedAt: 0,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -12,7 +12,7 @@ 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,
|
||||
createdAt: updatedAt, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
|
||||
@@ -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, createdAt: updatedAt, updatedAt, ...overrides,
|
||||
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),
|
||||
@@ -107,8 +107,11 @@ describe('WorkspaceBrowser', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
||||
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
|
||||
'按工作区', '单列表', '手动排序', '最近更新',
|
||||
])
|
||||
expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy()
|
||||
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')
|
||||
@@ -405,6 +408,11 @@ describe('WorkspaceBrowser', () => {
|
||||
|
||||
fireEvent.click(search)
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.click(document.body)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(search)
|
||||
fireEvent.change(input, { target: { value: 'kept' } })
|
||||
fireEvent.click(document.body)
|
||||
expect(search.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
Reference in New Issue
Block a user