From 8e0cb2bdba97994a690c42f76d1456529e109963 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:20:16 +0800 Subject: [PATCH 01/37] feat(client): improve workspace session browsing --- .../client/connection/src/client/fixture.ts | 17 ++-- .../src/client/contract/sessions-port.ts | 1 + .../runtime/src/client/sessions/lineage.ts | 2 + .../runtime/src/client/sessions/manager.ts | 20 +++-- .../runtime/src/client/sessions/service.ts | 4 + .../client/ui-primitives/src/Menu.module.css | 9 +++ packages/client/ui-primitives/src/Menu.tsx | 13 ++- .../src/client/WorkspaceBrowser.module.css | 26 +++++- .../src/client/WorkspaceBrowser.tsx | 81 ++++++++++++++----- .../client/ui-workspace/src/client/locales.ts | 12 +++ .../src/client/rows/Rows.module.css | 10 +-- .../ui-workspace/src/client/rows/Rows.tsx | 4 +- .../client/ui-workspace/src/client/stores.ts | 13 ++- .../client/ui-workspace/src/client/tree.ts | 40 ++++++--- packages/host/apiproxy/src/api-proxy.ts | 3 + .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 1 + packages/host/apiproxy/src/api/sessions.ts | 2 + 19 files changed, 198 insertions(+), 62 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 08837db9c5..07e437ecf1 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1387,10 +1387,11 @@ 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'), 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' }, + { 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' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) const modelSelections = new Map(sessions.map(session => [ @@ -2046,15 +2047,16 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return ok(request, { sessionId: requestedId }) } } + const createdAt = Date.now() const created: SessionSummary = { - sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, + sessionId: requestedId ?? sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, 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, blank: true, cwd }) + emitHost({ type: 'host/session-added', sessionId: created.sessionId, createdAt, blank: true, cwd }) } if (workspace !== undefined && options.failWorkspaceAttach) { emitSession() @@ -2121,15 +2123,16 @@ 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++}`), updatedAt: Date.now(), running: false, blank: false, + sessionId: sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, 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, blank: false, + type: 'host/session-added', sessionId: child.sessionId, createdAt, blank: false, parentSessionId: sessionId, ...source.cwd === undefined ? {} : { cwd: source.cwd }, }) diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts index 466e26fe12..5f694921b1 100644 --- a/packages/client/runtime/src/client/contract/sessions-port.ts +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -16,6 +16,7 @@ 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 } diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index cf8fa0834d..5e54cb59fd 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -17,6 +17,7 @@ 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). */ @@ -77,6 +78,7 @@ 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, diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 741b3d36b2..a6e7bf867c 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -540,8 +540,9 @@ 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, updatedAt: Date.now(), running: false, blank: true, + sessionId: result.value.sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}), } }) @@ -551,9 +552,11 @@ 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, - updatedAt: Date.now(), + createdAt, + updatedAt: createdAt, running: false, blank: true, } }) @@ -587,8 +590,9 @@ export class SessionManager { ? result.value.sessionId : workspaceAttachSessionId(result.error) if (childId !== undefined) { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { - sessionId: childId, updatedAt: Date.now(), running: false, blank: false, + sessionId: childId, createdAt, updatedAt: createdAt, running: false, blank: false, parentSessionId: opts.sessionId, ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), } }) @@ -615,8 +619,9 @@ 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, updatedAt: Date.now(), running: false, blank: true, agentPreset, + sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, agentPreset, } }) } @@ -783,8 +788,10 @@ 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, updatedAt: Date.now(), running: false, blank: frame.blank, + sessionId: frame.sessionId, createdAt, updatedAt: createdAt, + running: false, blank: frame.blank, ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), ...(frame.origin !== undefined ? { origin: frame.origin } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), @@ -1037,7 +1044,8 @@ export class SessionManager { const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( - prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running + prev !== undefined && prev.createdAt === entry.createdAt + && 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 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b7267402e..c8e5412696 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -66,6 +66,8 @@ 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> @@ -667,6 +669,7 @@ export class SessionsService implements ISessions { running: entry.running, ...(entry.completed ? { completed: true } : {}), blank: entry.blank, + createdAt: entry.createdAt, updatedAt: entry.updatedAt, ...(entry.pendingInteraction === undefined ? {} @@ -700,6 +703,7 @@ export class SessionsService implements ISessions { origin: 'subagent', running: child.activity === 'running', blank: false, + createdAt: 0, updatedAt: 0, } } else if (summary.displayTitle !== displayTitle) { diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 20c1584c1a..7bc0c5aced 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -115,6 +115,15 @@ background: var(--dsw-alias-interactive-bg-hover); } +.denseList .item { + min-height: 34px; + padding-block: 5px; +} + +.denseList .label { + padding-block: 4px; +} + .list.compactList, .submenu.compactList { min-width: 164px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ea7e51b478..46c30b8afb 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -62,6 +62,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. + * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). @@ -74,6 +75,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * both trigger and list for the pointer grace (default false keeps it open * until outside click/Escape/selection). The grace makes the 4px trigger->list * gap and a brief overshoot survivable; coming back cancels the close. + * @param props.dense - reduce vertical row spacing without changing the standard typography or card width. * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the @@ -85,18 +87,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * by a hairline; they stay visible while the items above scroll. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] selectedId?: string | undefined + selectedIds?: readonly string[] | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean + dense?: boolean compact?: boolean getAnchorRect?: () => DOMRect | null className?: string @@ -204,6 +208,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id + const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true return (
{entry.icon}} {entry.label} {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */} - {entry.id === selectedId && } + {selected && } {subOpen && entry.submenu !== undefined && (
@@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (
sidebar fill so it tracks the theme. */ .fade { position: absolute; left: 0; right: var(--dsh-session-list-edge-inset); bottom: 0; - height: 72px; + height: 24px; background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); pointer-events: none; } @@ -229,9 +229,9 @@ - var(--dsh-session-list-scrollbar-width) - var(--dsh-session-list-scrollbar-offset) ); - /* Clears the 72px bottom fade overlay: at scroll end the last row sits + /* Clears the compact bottom fade overlay: at scroll end the last row sits above the gradient instead of under it. */ - padding-bottom: 48px; + padding-bottom: 16px; scrollbar-gutter: stable; } @@ -258,6 +258,24 @@ margin-top: 4px; } +.sessionOverflowButton { + width: 100%; + height: 30px; + border: none; + border-radius: 8px; + padding: 0 12px 0 28px; + background: transparent; + cursor: pointer; + text-align: left; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); +} + +.sessionOverflowButton:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + .empty { padding: 16px 12px; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index a049b3f3d1..4faa479e70 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -1,6 +1,6 @@ /** * The workspace/session browsing region filling the sidebar shell's - * `sidebar.workspaces` hole: section header (title + group-by + add + * `sidebar.workspaces` hole: section header (title + view options + add * workspace), search, the grouped tree or flat list, and the workspace * dialogs. Wide state renders the full browser; rail state renders the two * region icons (search / add workspace), each requesting shell expansion @@ -19,7 +19,7 @@ import type { SessionSearchResultItem, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from './contract/slots.ts' -import type { SessionNode } from './tree.ts' +import type { SessionNode, SessionOrderBy } from './tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx' import { WorkspacePickFlow } from './WorkspacePicker.tsx' @@ -34,6 +34,8 @@ const EXPAND_SLIDE_MS = 300 const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 +/** Session rows visible per Workspace before the local overflow control. */ +const COLLAPSED_SESSION_LIMIT = 6 /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -51,10 +53,12 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu({ groupBy, onPick, t }: { +/** Grouping and ordering menu; own open state so it resets with the wide chrome. */ +function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { groupBy: 'workspace' | 'flat' - onPick: (mode: 'workspace' | 'flat') => void + orderBy: SessionOrderBy + onGroupPick: (mode: 'workspace' | 'flat') => void + onOrderPick: (mode: SessionOrderBy) => void t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) @@ -66,14 +70,19 @@ function GroupByMenu({ groupBy, onPick, t }: { { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, { 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: t('orderBy.manual'), disabled: groupBy !== 'workspace' }, + { id: 'created', label: t('orderBy.created') }, + { id: 'updated', label: t('orderBy.updated') }, ]} - selectedId={groupBy} + selectedIds={[groupBy, orderBy]} onSelect={(id) => { - /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ - if (id === 'workspace' || id === 'flat') onPick(id) + if (id === 'workspace' || id === 'flat') onGroupPick(id) + else if (id === 'manual' || id === 'created' || id === 'updated') onOrderPick(id) setOpen(false) }} align="end" + dense // Portal: the section header clips overflow, so an in-place list would // be cut off at the header's bounds. portal @@ -116,16 +125,19 @@ type SessionTreeProps = Pick< onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void /** Archive a session (row menu action; the row disappears on the state echo). */ onSessionArchive: (sessionId: SessionNode['id']) => void + /** Visual order; only manual mode exposes durable Workspace dragging. */ + orderBy: SessionOrderBy } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, - onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, orderBy, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) + const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(null) const currentGroup = current === undefined @@ -137,8 +149,8 @@ function SessionTree({ setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup])) }, [current, currentGroup]) const groups = useMemo( - () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }), - [list, workspaces, archivedSessionIds, expandedProjects], + () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy), + [list, workspaces, archivedSessionIds, expandedProjects, orderBy], ) const now = Date.now() @@ -173,11 +185,14 @@ function SessionTree({ }, }} /> - {group.sessions.map((node, index) => { + {(expandedSessionGroups.includes(group.key) + ? group.sessions + : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) + ).map((node, index) => { // Draggable: real-workspace session rows. The drag // never leaves its group — rows of other groups show no markers // and reject drops (visual movement confined to this section). - const draggable = group.workspaceId !== undefined + const draggable = group.workspaceId !== undefined && orderBy === 'manual' const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId const dragProps = !draggable || group.workspaceId === undefined ? undefined : { start: () => { @@ -223,6 +238,18 @@ function SessionTree({ /> ) })} + {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( + + )}
))}
@@ -232,11 +259,14 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< - SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' +function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, orderBy, t }: Pick< + SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 'orderBy' | 't' >) { const list = useSessions(s => s) - const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds]) + const rows = useMemo( + () => deriveFlat(list, archivedSessionIds, orderBy), + [list, archivedSessionIds, orderBy], + ) const now = Date.now() return (
@@ -367,6 +397,12 @@ export function WorkspaceBrowser({ // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) + // A live HMR handoff can retain the pre-ordering store instance until the + // slot is remounted; manual is the established Workspace order. + const orderBy = useStore(s => s.orderBy ?? 'manual') + // A flat list has no single Workspace account to drag. Keep the stored + // grouped preference intact while presenting the flat list by recency. + const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -548,7 +584,15 @@ export function WorkspaceBrowser({ {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} - {wide && { actions.setGroupBy(mode) }} t={t} />} + {wide && ( + { actions.setGroupBy(mode) }} + onOrderPick={(mode) => { actions.setOrderBy(mode) }} + t={t} + /> + )} {/* Adding is the button's one action, so a composition with no picking affordance has nothing to offer here: the region hides the button rather than leaving a dead one in the header. */} @@ -644,7 +688,7 @@ export function WorkspaceBrowser({ ) : ( @@ -658,6 +702,7 @@ export function WorkspaceBrowser({ startSession={startSession} open={open} insertSessionBefore={insertSessionBefore} + orderBy={orderBy} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 30fe6bfcc0..c816326157 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -13,6 +13,12 @@ export const zh = { 'groupBy.label': '分组方式', 'groupBy.workspace': '按工作区', 'groupBy.flat': '单列表', + 'orderBy.label': '排序方式', + 'orderBy.manual': '手动排序', + 'orderBy.created': '创建时间', + 'orderBy.updated': '最近更新', + 'sessions.expand': '展开其余 {n} 个会话', + 'sessions.collapse': '收起', 'empty.none': '暂无会话', 'empty.noMatches': '无匹配结果', 'workspace.add': '添加工作区', @@ -76,6 +82,12 @@ export const en = { 'groupBy.label': 'Group by', 'groupBy.workspace': 'WorkSpace', 'groupBy.flat': 'In one list', + 'orderBy.label': 'Order by', + 'orderBy.manual': 'Manual', + 'orderBy.created': 'Date created', + 'orderBy.updated': 'Last updated', + 'sessions.expand': 'Show {n} more sessions', + 'sessions.collapse': 'Show less', 'empty.none': 'No sessions yet', 'empty.noMatches': 'No matches', 'workspace.add': 'Add workspace', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 612eb3e306..71be79d081 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -82,14 +82,10 @@ color: var(--dsw-alias-label-secondary); } -/* Two-line row: the leading slot (folder/chevron), title, and trailing - actions all top-align on the 20px first text line (figma cell) — content - is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */ +/* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 54px; - align-items: flex-start; - padding-top: 6px; - padding-bottom: 6px; + height: 36px; + align-items: center; box-sizing: border-box; } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 71c0b05af5..5ed290f20d 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -67,7 +67,7 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { } /** - * Project (workspace) header row: 54px, folder + title + session count; + * Project (workspace) header row: folder + title; * hover reveals the chevron and create button, and dwelling on a real * Workspace shows its hover card (the ungrouped bucket has none). * `containsCurrent` arrives on the node (derivation fact, no renderer scan). @@ -89,7 +89,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { // The ungrouped bucket has no workspace title: its label is dictionary copy. const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label const active = group.expanded && group.containsCurrent - const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount }) const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, @@ -110,7 +109,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { {label} - {count} {actions !== undefined && ( diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index ed89d80d9e..91abedccfa 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -9,9 +9,11 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run /** Session-list grouping mode: workspace sections or one flat recency list. */ export type WorkspaceGroupBy = 'workspace' | 'flat' +/** Session order: durable Workspace order or a derived timestamp order. */ +export type WorkspaceOrderBy = 'manual' | 'created' | 'updated' -/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ -type WorkspaceViewState = { groupBy: WorkspaceGroupBy } +/** Workspace browser viewing state; transient expansion facts stay component-local. */ +type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy } /** * Annotation twin of the actions literal below (the export needs a declared @@ -19,6 +21,7 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy } */ type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void + setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void } /** @@ -27,10 +30,12 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), - persist: 'dsh.workspace.view', + init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual' }), + // The added order field changes the whole-value persistence format. + persist: 'dsh.workspace.view.v2', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 008ab687f7..5120e31931 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -29,9 +29,13 @@ 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 } +/** Session order selected by the Workspace browser. */ +export type SessionOrderBy = 'manual' | 'created' | 'updated' + /** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ @@ -104,6 +108,16 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } +/** Newest-created first, id as the deterministic tiebreak. */ +function byCreation(a: SessionSummary, b: SessionSummary): number { + if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt + return a.id < b.id ? -1 : 1 +} + +function sortSessions(sessions: SessionSummary[], orderBy: Exclude): void { + sessions.sort(orderBy === 'created' ? byCreation : byRecency) +} + /** * Ordinary sessions are visible; among blank sessions, only the current one * is visible. Subagent children use their parent header catalog; archived @@ -133,12 +147,10 @@ function buildGroup( createdAt: number | undefined, label: string, members: readonly SessionSummary[], - order: 'account' | 'recency', + orderBy: SessionOrderBy, ): Group { const sessions = [...members] - // Workspace order is workspace.sessionIds; only Ungrouped lacks an account - // order and therefore falls back to recency. - if (order === 'recency') sessions.sort(byRecency) + if (orderBy !== 'manual') sortSessions(sessions, orderBy) return { key, workspaceId, cwd, createdAt, label, sessions } } @@ -151,6 +163,7 @@ function groupByWorkspace( list: SessionListState, workspaces: readonly WorkspaceView[], archived: ReadonlySet, + orderBy: SessionOrderBy, ): Group[] { const groups: Group[] = [] const accounted = new Set() @@ -165,7 +178,7 @@ function groupByWorkspace( } groups.push(buildGroup( workspace.workspaceId, workspace.workspaceId, workspace.path, - Date.parse(workspace.createdAt), workspace.title, members, 'account', + Date.parse(workspace.createdAt), workspace.title, members, orderBy, )) } const stray = list.ids @@ -173,7 +186,10 @@ 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, 'recency')) + groups.push(buildGroup( + UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, + orderBy === 'manual' ? 'updated' : orderBy, + )) } return groups } @@ -189,6 +205,7 @@ 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 }), } @@ -213,6 +230,7 @@ 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) @@ -222,7 +240,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)) { + for (const g of groupByWorkspace(list, workspaces, archived, orderBy)) { const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, @@ -248,7 +266,11 @@ export function deriveGroups( * @param archivedSessionIds - registry-global archive set. * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] { +export function deriveFlat( + list: SessionListState, + archivedSessionIds: readonly SessionId[], + orderBy: SessionOrderBy = 'updated', +): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) const rows: SessionSummary[] = [] @@ -257,7 +279,7 @@ export function deriveFlat(list: SessionListState, archivedSessionIds: readonly if (s === undefined || !sessionVisible(s, list.current, archived)) continue rows.push(s) } - rows.sort(byRecency) + sortSessions(rows, orderBy === 'manual' ? 'updated' : orderBy) return rows.map(session => sessionNode(session, descendants)) } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c4e0a16756..b01ef9d636 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -466,6 +466,7 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[ function summarize(session: Session, running: boolean): SessionSummary { return { sessionId: session.id, + createdAt: session.header.createdAt, // Excludes end-seed: a resumed-but-untouched session // must not sort as freshly worked in. updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, @@ -499,6 +500,7 @@ async function summarizeCold( } return { sessionId: meta.id, + createdAt: meta.createdAt, updatedAt, running: false, // Lazy persistence keeps never-appended sessions out of list(); reading @@ -3377,6 +3379,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/session-added', sessionId: session.id, + createdAt: session.header.createdAt, // Derived at frame time like summarize(); a just-created session // has run no turn yet, so this is constantly true in practice. blank: sessionBlank(session), diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 02516c13bb..baa4b6101a 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -71,6 +71,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, + createdAt: z.number().optional(), blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 73bb9d8bc2..7069efecf3 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -127,6 +127,7 @@ export type HostFrame = | { type: 'host/session-added' sessionId: SessionId + createdAt?: number blank: boolean parentSessionId?: SessionId origin?: 'subagent' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 5c4647769a..18d162a95c 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -51,6 +51,7 @@ export const sessionEventSchema = z.object({ /** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ export const sessionSummarySchema = z.object({ sessionId: sessionIdSchema, + createdAt: z.number().optional(), updatedAt: z.number(), running: z.boolean(), blank: z.boolean(), diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index d1f0317e8f..24272dcf07 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -147,6 +147,8 @@ export type QueueAction = /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { sessionId: SessionId + /** Session creation time from the durable session header when supplied by the Host. */ + createdAt?: number /** * Last activity. Attached: the last non-`session/end-seed` event, since a * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend From 1d4ab4492e95efa2f6d0168f33869f9b40c0b3be Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:20:19 +0800 Subject: [PATCH 02/37] style(client): tighten sidebar layout --- packages/client/ui-layout/src/client/columns.ts | 4 ++-- .../src/client/SettingsRoot.module.css | 16 +++++++++------- .../ui-sidebar/src/client/SidebarRoot.module.css | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 51a944ef2a..374ce64703 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -20,10 +20,10 @@ export interface Columns { sidebar: number; center: number; details: number } /** Center column floor; only the final fallback may go below it. */ export const CENTER_MIN = 640 /** Sidebar drag clamp floor. */ -export const SIDEBAR_MIN = 280 +export const SIDEBAR_MIN = 264 /** Sidebar drag clamp ceiling. */ export const SIDEBAR_MAX = 420 -/** Sidebar width before any user drag (= the drag floor). */ +/** Sidebar width before any user drag. */ export const SIDEBAR_DEFAULT = 280 /** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */ export const SIDEBAR_COLLAPSED = 56 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index f1bd87e9af..711387fbb4 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -1,19 +1,20 @@ /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar - foot trigger row + centered 1080x700 modal panel. The trigger reproduces - the former sidebar foot geometry (49px wide row / 36px rail circle); the + foot trigger row + centered 1080x700 modal panel. The trigger uses the + sidebar's 38px wide row / 36px rail circle rhythm; the panel is a two-column layout — 188px nav rail + content column with a 54px header and the 24px-padded options area. */ -/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */ +/* Trigger row: match the other wide sidebar controls' compact vertical rhythm. */ .trigger { flex: none; display: flex; align-items: center; gap: 8px; width: 100%; - height: 49px; - margin: 8px 0 0; - padding: 0 2px 0 6px; + height: 38px; + margin: 4px 0 0; + padding: 8px 2px 8px 6px; + box-sizing: border-box; border: none; border-radius: 12px; background: transparent; @@ -22,6 +23,7 @@ color: var(--dsw-alias-label-primary); font-family: inherit; font-size: 14px; + line-height: 22px; } .trigger:hover { @@ -32,7 +34,7 @@ .trigger.rail { width: 36px; height: 36px; - margin: 18px 0 10px; + margin: 8px 0 10px; justify-content: center; gap: 0; padding: 0; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 67310853a2..47a7a40967 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -224,7 +224,7 @@ } /* Foot seat: a pure layout socket pinned under the region; the ui-settings - trigger row inside owns its own geometry (49px wide row / 36px rail + trigger row inside owns its own geometry (38px wide row / 36px rail circle) and hover chrome. */ .footArea { flex: none; From b3e843056e2d71db38e909a646836d558750186f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:43:47 +0800 Subject: [PATCH 03/37] feat(workspace): support persistent workspace ordering --- .../client/connection/src/client/fixture.ts | 33 +++++++ .../runtime/src/client/contract/workspaces.ts | 6 ++ .../runtime/src/client/workspaces/manager.ts | 90 ++++++++++++++++--- .../runtime/src/client/workspaces/service.ts | 10 +++ .../client/test-runtime/src/workspaces.ts | 10 +++ packages/host/apiproxy/src/api-proxy.ts | 27 +++++- .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 4 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 11 +++ packages/host/apiproxy/src/api/workspace.ts | 9 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + packages/workspace/workspace/src/index.ts | 35 ++++++++ 14 files changed, 230 insertions(+), 13 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 07e437ecf1..2a75d77e29 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2408,6 +2408,38 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { emitHost({ type: 'host/workspace-removed', workspaceId }) return ok(request, { deleted: true as const }) }, + insertBefore: (request) => { + const { workspaceId, beforeWorkspaceId } = request.payload + const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + const anchor = beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId) + const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined + if (missing !== undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${missing}`, + details: { workspaceId: missing }, + }) + } + if (beforeWorkspaceId !== workspaceId) { + const previousOrder = workspaces.map(candidate => candidate.workspaceId) + const [workspace] = workspaces.splice(source, 1) + /* v8 ignore next -- source was resolved from the same array immediately above. */ + if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`) + const at = beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId) + workspaces.splice(at, 0, workspace) + if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { + emitHost({ + type: 'host/workspace-order-changed', + workspaceIds: workspaces.map(candidate => candidate.workspaceId), + }) + } + } + return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) + }, insertSessionBefore: (request) => { const { workspaceId, sessionId, beforeSessionId } = request.payload const workspace = workspaces.find(w => w.workspaceId === workspaceId) @@ -2949,6 +2981,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) case 'workspace.delete': return this.api.workspace.delete(request) + case 'workspace.insertBefore': return this.api.workspace.insertBefore(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'command.list': return this.api.commands.list(request) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index ad896bbdaf..a541887df0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -68,6 +68,12 @@ export interface IWorkspaces { * @param workspaceId - target workspace. */ delete(workspaceId: WorkspaceId): Promise + /** + * Move a Workspace within the registry display order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + */ + insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise /** * Move an accounted session within/into a Workspace's ordered list. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index df3aa8fe28..89f96295ef 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -4,7 +4,6 @@ import type { HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { Notifier } from '../sessions/notifier.ts' import { Workspace, type WorkspaceCreateInput } from './workspace.ts' @@ -30,6 +29,7 @@ export interface WorkspaceListSnapshot { type WorkspaceDelta = | { type: 'upsert'; workspace: WorkspaceView } | { type: 'remove'; workspaceId: WorkspaceId } + | { type: 'order'; workspaceIds: readonly WorkspaceId[] } /** Workspace object cluster driven by one list baseline and changed-frame upserts. */ export class WorkspaceManager { @@ -51,6 +51,10 @@ export class WorkspaceManager { * mirror of replaying refreshFrames over the item baseline. */ private archivedSupersedesRefresh = false + /** Latest local reorder request; only its unary echo may install order. */ + private orderRequestGeneration = 0 + /** Increments on order frames so a later remote commit outranks an older unary echo. */ + private orderFrameGeneration = 0 /** * Ids this process has seen removed, kept for the connection's lifetime so * a late changed frame or a stale baseline row cannot resurrect a deleted @@ -72,16 +76,15 @@ export class WorkspaceManager { /** * Refresh from workspace.list. The first successful response establishes - * Host order; later responses update membership and values without moving - * identities already visible to the client. Frames arriving during the RPC - * are replayed over its response. + * Host order; later responses re-establish the durable order so reconnects + * adopt reorders committed while this client was offline. Frames arriving + * during the RPC are replayed over its response. * @returns the shared in-flight refresh. */ refresh(): Promise { if (this.inflight !== null) return this.inflight this.state = 'loading' this.error = null - const established = this.itemViews() const frames: WorkspaceDelta[] = [] this.refreshFrames = frames this.notifier.markDirty() @@ -89,9 +92,7 @@ export class WorkspaceManager { try { const { result } = await this.api.workspace.list({}) if (result.ok) { - let items = this.phase === 'pending' - ? result.value.items - : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) + let items = result.value.items items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) @@ -157,6 +158,35 @@ export class WorkspaceManager { return result } + /** + * Move a Workspace within the registry display order and install the full + * returned order without waiting for the Host frame. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + * @returns the wire result. + */ + async insertBefore( + workspaceId: WorkspaceId, + beforeWorkspaceId?: WorkspaceId, + ): Promise> { + const requestGeneration = ++this.orderRequestGeneration + const frameGeneration = this.orderFrameGeneration + const previousOrder = this.itemViews().map(workspace => workspace.workspaceId) + this.installOrder(insertIdBefore(previousOrder, workspaceId, beforeWorkspaceId)) + const { result } = await this.api.workspace.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + }) + if (result.ok && requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(result.value.workspaceIds) + } else if (!result.ok && requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(previousOrder) + } + return result + } + /** * Move a session within its Workspace's manual order, then publish the * returned snapshot without waiting for the changed frame. @@ -198,6 +228,10 @@ export class WorkspaceManager { handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) + else if (envelope.payload.type === 'host/workspace-order-changed') { + this.orderFrameGeneration++ + this.installOrder(envelope.payload.workspaceIds) + } else if (envelope.payload.type === 'host/archived-sessions-changed') { this.installArchived(envelope.payload.archivedSessionIds) } @@ -249,6 +283,21 @@ export class WorkspaceManager { this.notifier.markDirty() } + /** Reorder known Workspace objects by a complete Host id sequence. */ + private installOrder(workspaceIds: readonly WorkspaceId[]): void { + this.refreshFrames?.push({ type: 'order', workspaceIds }) + const rank = new Map(workspaceIds.map((id, index) => [id, index])) + const items = [...this.items].sort((left, right) => { + const leftId = left.getSnapshot().view?.workspaceId + const rightId = right.getSnapshot().view?.workspaceId + return (leftId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(leftId) ?? Number.MAX_SAFE_INTEGER) + - (rightId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(rightId) ?? Number.MAX_SAFE_INTEGER) + }) + if (items.every((item, index) => item === this.items[index])) return + this.items = items + this.notifier.markDirty() + } + /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { if (this.removedIds.has(view.workspaceId)) return @@ -332,7 +381,26 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi /** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { - return delta.type === 'upsert' - ? upsertWorkspace(items, delta.workspace) - : items.filter(workspace => workspace.workspaceId !== delta.workspaceId) + if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace) + if (delta.type === 'remove') { + return items.filter(workspace => workspace.workspaceId !== delta.workspaceId) + } + const rank = new Map(delta.workspaceIds.map((id, index) => [id, index])) + return [...items].sort((left, right) => + (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER) + - (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER)) +} + +/** Move one known id before an optional anchor; unknown ids leave the order unchanged. */ +function insertIdBefore( + ids: readonly WorkspaceId[], + id: WorkspaceId, + beforeId?: WorkspaceId, +): WorkspaceId[] { + if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) { + return [...ids] + } + const without = ids.filter(candidate => candidate !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + return [...without.slice(0, at), id, ...without.slice(at)] } diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 468ae95a19..8b26d0f1b0 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -265,6 +265,16 @@ export class WorkspacesService implements IWorkspaces { if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) } + /** + * Move a Workspace within the durable registry display order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + */ + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + const result = await this.manager.insertBefore(workspaceId, beforeWorkspaceId) + if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`) + } + /** * Archive a session into the registry-global set. Clearing an archived * current selection is the projection sweep's job (one rule for the local diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 9e1061ec8c..4f6b2122cb 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -172,6 +172,16 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('delete')?.(workspaceId) as Promise | undefined) } + /** + * Move a Workspace in display order (recorded; default no-op). + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor; omitted appends. + */ + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + this.calls.push({ method: 'insertBefore', args: [workspaceId, beforeWorkspaceId] }) + await (this.stubs.get('insertBefore')?.(workspaceId, beforeWorkspaceId) as Promise | undefined) + } + /** * Move an accounted session (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index b01ef9d636..f21d3e9a86 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -25,7 +25,7 @@ import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, + WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { @@ -2671,6 +2671,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { deleted: true as const }) }, + async insertBefore(request) { + const { workspaceId, beforeWorkspaceId } = request.payload + try { + const workspaceIds = await ctx.workspace.insertBefore( + brandWorkspaceId(workspaceId), + beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId), + ) + return ok(request, { workspaceIds: [...workspaceIds] }) + } catch (error: unknown) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error + return workspaceNotFound(request, error.workspaceId) + } + }, + async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) @@ -3370,6 +3384,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const committedWorkspaceIds = new Set( ctx.workspace.list().map(workspace => String(workspace.id)), ) + let committedWorkspaceOrder = ctx.workspace.list().map(workspace => workspaceView(workspace).workspaceId) // Frame-dedup baseline, same posture as committedWorkspaceIds: the // stream opens against the current set; workspace.list re-baselines // reconnecting clients, so only later changes need frames. @@ -3401,6 +3416,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (change.table === '') { if (change.operation !== 'put') return const state = workspaceDomainState.parse(change.value) + const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length + && state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId))) + && state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index]) for (const workspaceId of state.workspaceIds) { if (committedWorkspaceIds.has(workspaceId)) continue const workspace = ctx.workspace.get(workspaceId) @@ -3410,6 +3428,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + committedWorkspaceOrder = [...state.workspaceIds] + if (orderChanged) { + queue.push(frame({ + type: 'host/workspace-order-changed', + workspaceIds: [...state.workspaceIds], + })) + } if (state.archivedSessionIds.length !== archivedSessionIds.length || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { archivedSessionIds = state.archivedSessionIds diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index baa4b6101a..4bdd8d3a24 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -83,6 +83,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), + z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 7069efecf3..5a116a1f9d 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -119,7 +119,8 @@ export type MuxFrame = * workspace mutation (create/attach/order change — the client upserts, while * `workspace.list` provides the reconnect baseline); workspace-removed is the * committed registration-deletion increment and never implies directory or - * session-log deletion; archived-sessions-changed pushes the full registry + * session-log deletion; workspace-order-changed pushes the complete durable + * registry order after a reorder; archived-sessions-changed pushes the full registry * archive set after every durable change (same full-snapshot posture as * workspace-changed — `workspace.list` re-baselines it on reconnect). */ @@ -139,6 +140,7 @@ export type HostFrame = | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } + | { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] } | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * The command registry changed (`commands/change` passthrough). Pure diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index ca7231e774..3c06f1e9d5 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -48,6 +48,7 @@ export interface RpcMethodMap { 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] + 'workspace.insertBefore': WorkspaceApi['insertBefore'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'command.list': CommandsApi['list'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 5ad5a0b96b..b57305141c 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -66,6 +66,17 @@ export const workspaceDeleteValueSchema = z.object({ deleted: z.literal(true), }) satisfies z.ZodType>> +/** workspace.insertBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + beforeWorkspaceId: workspaceIdSchema.optional(), +}) satisfies z.ZodType>> + +/** workspace.insertBefore response value: the complete durable display order. */ +export const workspaceInsertBeforeValueSchema = z.object({ + workspaceIds: z.array(workspaceIdSchema), +}) satisfies z.ZodType>> + /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ export const workspaceInsertSessionBeforeRequestSchema = z.object({ workspaceId: workspaceIdSchema, diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 64feb27f80..d36d0c406e 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -73,6 +73,15 @@ export interface WorkspaceApi { delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): Promise> + /** + * Moves one Workspace within the registry display order, + * DOM-insertBefore-like. An omitted anchor appends to the end. + */ + insertBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + beforeWorkspaceId?: WorkspaceId + }>): Promise> + /** * Moves an accounted session within its workspace's manual order, * DOM-insertBefore-like: with `beforeSessionId` the session is inserted diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 6060875a6f..49e5fce67f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -35,6 +35,7 @@ import { workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, workspaceRenameValueSchema, @@ -117,6 +118,7 @@ export interface IApiClient { create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> + insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } @@ -198,6 +200,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.create', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), + insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1e902f059e..f356c9f7bd 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -38,6 +38,7 @@ import { workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, workspaceRenameRequestSchema, @@ -113,6 +114,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, + 'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d972085939..5d1f3296d8 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -52,6 +52,17 @@ export class WorkspaceUnknownSessionError extends Error { } } +/** A workspace reorder named a source or anchor absent from the durable registry order. */ +export class WorkspaceOrderInvalidError extends Error { + /** + * @param workspaceId - Missing source or anchor id. + */ + constructor(readonly workspaceId: WorkspaceId) { + super(`cannot reorder unknown workspace '${workspaceId}'`) + this.name = 'WorkspaceOrderInvalidError' + } +} + declare module '@deepseek-ai/cordis' { interface Context { @@ -189,6 +200,30 @@ export class WorkspaceRegistry extends Service { return this.enqueueOperation(() => this.deleteKnown(id)) } + /** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ + insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise { + return this.enqueueOperation(async () => { + const state = this.requireState() + if (!state.workspaceIds.includes(id)) throw new WorkspaceOrderInvalidError(id) + if (beforeId !== undefined && !state.workspaceIds.includes(beforeId)) { + throw new WorkspaceOrderInvalidError(beforeId) + } + if (beforeId === id) return state.workspaceIds + const without = state.workspaceIds.filter(workspaceId => workspaceId !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + const workspaceIds = [...without.slice(0, at), id, ...without.slice(at)] + if (sameIds(workspaceIds, state.workspaceIds)) return state.workspaceIds + await this.setState({ ...state, workspaceIds }) + return workspaceIds + }) + } + /** * The registry-global archive set: sessions hidden from every grouping * surface. Archiving never touches workspace accounting — an archived From 1a1729a138f274163372bd1fd402e51b73938978 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:43:51 +0800 Subject: [PATCH 04/37] feat(client): refine workspace sidebar interactions --- .../src/client/WorkspaceBrowser.module.css | 163 +++++++++--- .../src/client/WorkspaceBrowser.tsx | 231 +++++++++++++----- .../ui-workspace/src/client/contract/slots.ts | 5 + .../client/ui-workspace/src/client/index.ts | 3 + .../src/client/rows/Rows.module.css | 34 ++- .../ui-workspace/src/client/rows/Rows.tsx | 82 +++++-- 6 files changed, 394 insertions(+), 124 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index f44287ed8a..270b8902d6 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -38,9 +38,8 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Section header: 36px, "Workspaces/Sessions" label + group-by / - new-workspace buttons; the right-anchored new-workspace button is the - row's rail survivor. */ +/* Section header: title, an inline search control, and the two trailing + actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { flex: none; display: flex; @@ -48,7 +47,7 @@ justify-content: flex-end; gap: 4px; height: 36px; - padding-left: 12px; + padding-left: 4px; margin-bottom: 4px; box-sizing: border-box; border-radius: 12px; @@ -56,64 +55,126 @@ color: var(--dsw-alias-label-tertiary); } +.root:not(.rail) .sectionHeader { + margin-right: -4px; +} + .sectionLabel { - flex: 1; + flex: none; + max-width: 45%; min-width: 0; overflow: hidden; white-space: nowrap; line-height: 20px; } -/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off - corners); rail state renders it as the - region's search control. Upstream binds a dedicated design-system variable - (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component - token pinned to the static scale mirrors it. */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); +.searchSlot { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + padding-left: 4px; + box-sizing: border-box; +} + +.headerActions { flex: none; display: flex; align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; - padding: 0 14px; + gap: 4px; + max-width: 60px; + opacity: 1; + overflow: hidden; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; +} + +.headerActionsHidden { + max-width: 0; + opacity: 0; + transform: translateX(4px); + visibility: hidden; + pointer-events: none; + transition-delay: 0s, 0s, 0s, 180ms; +} + +/* Inline search always fills the room between the title and trailing actions; + it grows farther right when the action cluster collapses. */ +.search { + flex: none; + display: flex; + align-items: center; + gap: 0; + width: 100%; + height: 26px; + margin: 0; + padding: 0; box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsh-search-input-fill); + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 10px; + background: transparent; + cursor: text; color: var(--dsw-alias-label-caption); overflow: hidden; + transition: + width 180ms var(--ds-ease-in-out), + padding 180ms var(--ds-ease-in-out), + border-color 180ms var(--ds-ease-in-out), + background-color 180ms var(--ds-ease-in-out); } -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +.searchExpanded { + padding: 0 4px 0 0; + border-color: var(--dsw-alias-border-l2); + background: transparent; } -/* The capsule's leading icon: decorative while wide (pointer-events off so - clicks reach the input), the hit target in rail state. */ .searchButton { flex: none; display: inline-flex; align-items: center; justify-content: center; + width: 26px; + height: 26px; border: none; border-radius: 50%; padding: 0; background: transparent; - pointer-events: none; + cursor: pointer; color: inherit; } +.searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.searchExpanded .searchButton:hover { + background: transparent; +} + .searchInput { flex: 1; + width: 0; min-width: 0; border: none; outline: none; background: transparent; - font-size: 14px; - line-height: 20px; + opacity: 0; + pointer-events: none; + font-size: 13px; + line-height: 18px; color: var(--dsw-alias-label-primary); + transition: opacity 120ms var(--ds-ease-in-out); +} + +.searchExpanded .searchInput { + margin-left: -2px; + opacity: 1; + pointer-events: auto; } .searchInput::placeholder { @@ -125,8 +186,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 18px; + height: 18px; border: none; border-radius: 50%; padding: 0; @@ -144,6 +205,10 @@ margin-bottom: 12px; } +.rail .headerActions { + max-width: none; +} + .rail .iconButton { width: 36px; height: 36px; @@ -151,6 +216,7 @@ } .rail .search { + width: 36px; height: 36px; padding: 0; margin: 0 0 12px; @@ -162,8 +228,6 @@ .rail .searchButton { width: 36px; height: 36px; - pointer-events: auto; - cursor: pointer; color: var(--dsw-alias-label-primary); } @@ -254,10 +318,35 @@ } /* One workspace section: header row + a compact expanded session run. */ +.groupSection { + position: relative; +} + .groupSection + .groupSection { margin-top: 4px; } +.workspaceDropBefore::before, +.workspaceDropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 4px; + right: 4px; + height: 2px; + border-radius: 999px; + background: var(--dsw-alias-state-business-primary); + pointer-events: none; +} + +.workspaceDropBefore::before { + top: -3px; +} + +.workspaceDropAfter::after { + bottom: -3px; +} + .sessionOverflowButton { width: 100%; height: 30px; @@ -271,9 +360,15 @@ color: var(--dsw-alias-label-tertiary); } +.groupSection > .sessionOverflowButton { + margin-top: 0; +} + .sessionOverflowButton:hover { - background: var(--dsw-alias-interactive-bg-hover); + background: transparent; color: var(--dsw-alias-label-secondary); + text-decoration: underline; + text-underline-offset: 2px; } .empty { @@ -323,4 +418,10 @@ .wide { animation: none; } + + .search, + .searchInput, + .headerActions { + transition: none; + } } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 4faa479e70..294b39b59a 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -35,7 +35,7 @@ const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ -const COLLAPSED_SESSION_LIMIT = 6 +const COLLAPSED_SESSION_LIMIT = 5 /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -110,9 +110,16 @@ interface DragState { over: { id: SessionNode['id']; half: 'before' | 'after' } | null } +/** In-flight Workspace-row drag: source identity plus the current marker. */ +interface WorkspaceDragState { + workspaceId: WorkspaceId + over: { id: WorkspaceId; half: 'before' | 'after' } | null +} + type SessionTreeProps = Pick< WorkspaceBrowserProps, - 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't' + 'useSessions' | 'startSession' | 'open' | 'forkSession' + | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { workspaces: readonly WorkspaceView[] /** Registry-global archive set (hidden rows). */ @@ -125,14 +132,15 @@ type SessionTreeProps = Pick< onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void /** Archive a session (row menu action; the row disappears on the state echo). */ onSessionArchive: (sessionId: SessionNode['id']) => void - /** Visual order; only manual mode exposes durable Workspace dragging. */ + /** Session visual order; only manual mode exposes durable Session dragging. */ orderBy: SessionOrderBy } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, - onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, orderBy, t, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, + insertWorkspaceBefore, insertSessionBefore, orderBy, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -140,6 +148,7 @@ function SessionTree({ const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(null) + const [workspaceDrag, setWorkspaceDrag] = useState(null) const currentGroup = current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) @@ -160,18 +169,62 @@ function SessionTree({ {groups.length === 0 && (
{t('empty.none')}
)} - {groups.map(group => ( + {groups.map((group) => { + const workspaceId = group.workspaceId + const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId + ? workspaceDrag.over.half + : null + const workspaceDragProps = workspaceId === undefined ? undefined : { + start: () => { setWorkspaceDrag({ workspaceId, over: null }) }, + active: workspaceDrag !== null, + marker: null, + hover: (half: 'before' | 'after') => { + setWorkspaceDrag(active => active === null + ? active + : { ...active, over: { id: workspaceId, half } }) + }, + drop: (half: 'before' | 'after') => { + if (workspaceDrag === null) return + const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId + setWorkspaceDrag(null) + if (anchor === workspaceDrag.workspaceId) return + const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceDrag.workspaceId) + const anchorIndex = anchor === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => { + console.warn('workspace reorder rejected:', reason) + }) + }, + end: () => { setWorkspaceDrag(null) }, + } + return ( // Group section: header row + expanded top-level session rows. The // inter-group breathing room is the section's own margin // (WorkspaceBrowser.module.css). -
+
{ setExpandedProjects(l => toggled(l, group.key)) }} + onToggle={() => { + if (group.expanded) { + setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) + } + setExpandedProjects(l => toggled(l, group.key)) + }} onCreate={() => { if (group.workspaceId !== undefined) startSession(group.workspaceId) }} + drag={workspaceDragProps} actions={group.workspaceId === undefined ? undefined : { @@ -251,7 +304,8 @@ function SessionTree({ )}
- ))} + ) + })}
@@ -382,6 +436,7 @@ export function WorkspaceBrowser({ forkSession, renameWorkspace, deleteWorkspace, + insertWorkspaceBefore, archiveSession, insertSessionBefore, createWorkspace, @@ -406,6 +461,7 @@ export function WorkspaceBrowser({ // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') + const [searchExpanded, setSearchExpanded] = useState(false) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', @@ -413,6 +469,7 @@ export function WorkspaceBrowser({ items: [], hasMore: false, }) + const searchRoot = useRef(null) const searchInput = useRef(null) // Section-header + opens the picker menu (same popover in wide and rail // states; the menu anchors on this button). @@ -433,6 +490,21 @@ export function WorkspaceBrowser({ } }, [wide, searchOnExpand]) + useEffect(() => { + if (!wide || !searchExpanded || searchOnExpand) return + searchInput.current?.focus({ preventScroll: true }) + }, [wide, searchExpanded, searchOnExpand]) + + useEffect(() => { + if (!wide || !searchExpanded) return + const onPointerDown = (event: PointerEvent): void => { + if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return + searchInput.current?.blur() + } + document.addEventListener('pointerdown', onPointerDown) + return () => { document.removeEventListener('pointerdown', onPointerDown) } + }, [wide, searchExpanded]) + useEffect(() => { if (normalizedQuery === '') { setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false }) @@ -585,32 +657,91 @@ export function WorkspaceBrowser({ )} {wide && ( - { actions.setGroupBy(mode) }} - onOrderPick={(mode) => { actions.setOrderBy(mode) }} - t={t} - /> - )} - {/* Adding is the button's one action, so a composition with no - picking affordance has nothing to offer here: the region hides the - button rather than leaving a dead one in the header. */} - {directoryFlowAvailable && ( - - - + + + + { setQuery(sanitizeSearchQuery(e.target.value)) }} + onKeyDown={(e) => { + if (e.key !== 'Escape') return + setQuery('') + setSearchExpanded(false) + }} + /> + {searchExpanded && ( + + )} +
+ )} +
+ {wide && ( + { actions.setGroupBy(mode) }} + onOrderPick={(mode) => { actions.setOrderBy(mode) }} + t={t} + /> + )} + {/* Adding is the button's one action, so a composition with no + picking affordance has nothing to offer here: the region hides the + button rather than leaving a dead one in the header. */} + {directoryFlowAvailable && ( + + + + )} +
{/* Add flow + its error dialog (same package — direct composition). */} - {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Rail: the icon is the region's search control. */} -
{ if (wide) searchInput.current?.focus() }}> - + {/* The collapsed rail keeps search as its own 36px control. */} + {!wide &&
+ - {wide && ( - { setQuery(sanitizeSearchQuery(e.target.value)) }} - /> - )} - {wide && query !== '' && ( - - )} -
+
} {/* Always-mounted seat keeps the region's flex slot while the list itself is wide-only. */} @@ -701,6 +813,7 @@ export function WorkspaceBrowser({ archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} + insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} orderBy={orderBy} t={t} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e1c41c9c17..e5487d2657 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Reorder a Workspace in the durable registry display order. + * Omitted anchor appends to the end. + */ + insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise /** * Archive a Session into the registry-global set: hidden from grouping * surfaces, log and accounting slot retained. Archiving the current diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 5f499c4336..41e88116fc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -91,6 +91,9 @@ export function apply(ctx: ClientContext): void { }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => { + await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId) + }, archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 71be79d081..0880820e4a 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -21,7 +21,7 @@ } .sessionRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultRow { @@ -45,7 +45,7 @@ } .searchResultRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultHeading { @@ -229,14 +229,32 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Drag reorder insert line (workspace-group session rows): 2px accent above or - below the hovered row, drawn with box-shadow so no layout shift. */ -.sessionRow.dropBefore { - box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +/* Session drag insert line: an independent 2px rule between rows, absolutely + positioned so it neither resembles a row border nor changes layout. */ +.sessionRow.dropBefore, +.sessionRow.dropAfter { + position: relative; } -.sessionRow.dropAfter { - box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +.sessionRow.dropBefore::before, +.sessionRow.dropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 4px; + right: 4px; + height: 2px; + border-radius: 999px; + background: var(--dsw-alias-state-business-primary); + pointer-events: none; +} + +.sessionRow.dropBefore::before { + top: -2px; +} + +.sessionRow.dropAfter::after { + bottom: -2px; } /* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 5ed290f20d..0903bbb600 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -66,6 +66,29 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { ) } +/** + * Row drag wiring supplied by the tree owner. `drop` reports the half of the + * row where the pointer released so the owner can resolve an insert anchor. + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A compatible row drag is in flight. */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a compatible drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + /** * Project (workspace) header row: folder + title; * hover reveals the chevron and create button, and dwelling on a real @@ -74,15 +97,18 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { * @param props.group - derived group node. * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. + * @param props.drag - optional workspace-row drag wiring. * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: { group: GroupNode onToggle: () => void onCreate: () => void /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ actions?: { rename: () => void; delete: () => void } | undefined + /** Present only for real Workspace rows in the grouped view. */ + drag?: RowDragProps | undefined t: RowTranslate }) { const row = group @@ -96,10 +122,37 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { ] const ownRow = (
{ + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', row.key) + drag.start() + }} + onDragEnd={drag?.end} + onDragOver={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + drag.hover(rowHalf(e)) + }} + onDrop={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + drag.drop(rowHalf(e)) + }} > {row.expanded ? : } @@ -237,24 +290,6 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; ) } -/** - * Session-row drag wiring supplied by the group owner (workspace groups only). - * `drop` reports the half of the row the pointer released on: 'before' - * inserts above this row, 'after' below it (the owner resolves the anchor). - */ -export interface RowDragProps { - /** Start dragging this row. */ - start: () => void - /** A drag from the same group is in flight (rows show insert markers). */ - active: boolean - /** Current marker on this row: insert line above, below, or none. */ - marker: 'before' | 'after' | null - /** Report the hovered half while a same-group drag passes over this row. */ - hover: (half: 'before' | 'after') => void - drop: (half: 'before' | 'after') => void - end: () => void -} - /** * One flat search result: title, Workspace context, and optional content * excerpt. Search navigation opens the session only; it does not address an @@ -303,12 +338,6 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { ) } -/** Pointer-position half of a row (insert line above or below). */ -function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { - const rect = e.currentTarget.getBoundingClientRect() - return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' -} - /** * One top-level 34px session row: status dot (pending user interaction outranks * own or descendant activity), title, relative time, and the row actions menu. @@ -368,6 +397,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork ? undefined : (e) => { e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', node.id) drag.start() }} onDragEnd={drag?.end} From e713cc820dd89823bd435c989a6a7e271753b7e5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:43:54 +0800 Subject: [PATCH 05/37] style(client): polish sidebar spacing --- .../client/ui-settings/src/client/SettingsRoot.module.css | 6 +++--- .../client/ui-sidebar/src/client/SidebarRoot.module.css | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 711387fbb4..dd3cd9661c 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -10,10 +10,10 @@ display: flex; align-items: center; gap: 8px; - width: 100%; + width: calc(100% - 8px); height: 38px; - margin: 4px 0 0; - padding: 8px 2px 8px 6px; + margin: 4px 4px 4px; + padding: 8px 2px 8px 10px; box-sizing: border-box; border: none; border-radius: 12px; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 47a7a40967..2cc99e3159 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -167,7 +167,7 @@ gap: 6px; height: 38px; padding: 8px 16px; - margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ + margin: 0 2px 8px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; From 325da4dab1a08aeb08c0f1e70823e8c7cc93f499 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:10:10 +0800 Subject: [PATCH 06/37] fix(client): preserve workspace browser interactions --- .../src/client/WorkspaceBrowser.tsx | 74 ++++++++++++++----- .../ui-workspace/src/client/rows/Rows.tsx | 28 ++----- .../client/ui-workspace/src/client/stores.ts | 16 ++-- 3 files changed, 74 insertions(+), 44 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 294b39b59a..016682c0c3 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -36,6 +36,7 @@ const SEARCH_DEBOUNCE_MS = 250 const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 +const EMPTY_WORKSPACE_EXPANSION: Readonly> = Object.freeze({}) /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -48,7 +49,7 @@ function sanitizeSearchQuery(value: string): string { return withoutNul.slice(0, end) } -/** Immutable membership toggle for the local expansion arrays. */ +/** Immutable membership toggle for the local expand-all array. */ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } @@ -116,12 +117,22 @@ interface WorkspaceDragState { over: { id: WorkspaceId; half: 'before' | 'after' } | null } +/** Resolve an insertion side from the full rendered workspace group. */ +function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + type SessionTreeProps = Pick< WorkspaceBrowserProps, 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { workspaces: readonly WorkspaceView[] + /** Explicit persisted zero-or-five-session state by Workspace group. */ + workspaceExpansion: Readonly> + /** Persist one Workspace group's zero-or-five-session state. */ + setWorkspaceExpanded: (key: string, expanded: boolean) => void /** Registry-global archive set (hidden rows). */ archivedSessionIds: readonly SessionNode['id'][] /** Open the browser-owned rename dialog for a real Workspace group. */ @@ -136,15 +147,15 @@ type SessionTreeProps = Pick< orderBy: SessionOrderBy } -/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ +/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, - insertWorkspaceBefore, insertSessionBefore, orderBy, t, + insertWorkspaceBefore, insertSessionBefore, orderBy, + workspaceExpansion, setWorkspaceExpanded, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current - const [expandedProjects, setExpandedProjects] = useState([]) const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(null) @@ -154,9 +165,13 @@ function SessionTree({ : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) ?? UNGROUPED_KEY useEffect(() => { - if (current === undefined || currentGroup === undefined) return - setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup])) - }, [current, currentGroup]) + if (current === undefined || currentGroup === undefined || Object.hasOwn(workspaceExpansion, currentGroup)) return + setWorkspaceExpanded(currentGroup, true) + }, [current, currentGroup, setWorkspaceExpanded, workspaceExpansion]) + const expandedProjects = useMemo( + () => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key), + [workspaceExpansion], + ) const groups = useMemo( () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy), [list, workspaces, archivedSessionIds, expandedProjects, orderBy], @@ -176,14 +191,18 @@ function SessionTree({ : null const workspaceDragProps = workspaceId === undefined ? undefined : { start: () => { setWorkspaceDrag({ workspaceId, over: null }) }, - active: workspaceDrag !== null, - marker: null, - hover: (half: 'before' | 'after') => { + end: () => { setWorkspaceDrag(null) }, + } + const hoverWorkspace = workspaceId === undefined + ? undefined + : (half: 'before' | 'after') => { setWorkspaceDrag(active => active === null ? active : { ...active, over: { id: workspaceId, half } }) - }, - drop: (half: 'before' | 'after') => { + } + const dropWorkspace = workspaceId === undefined + ? undefined + : (half: 'before' | 'after') => { if (workspaceDrag === null) return const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId @@ -197,9 +216,7 @@ function SessionTree({ insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => { console.warn('workspace reorder rejected:', reason) }) - }, - end: () => { setWorkspaceDrag(null) }, - } + } return ( // Group section: header row + expanded top-level session rows. The // inter-group breathing room is the section's own margin @@ -211,6 +228,19 @@ function SessionTree({ workspaceMarker === 'before' && css.workspaceDropBefore, workspaceMarker === 'after' && css.workspaceDropAfter, )} + onDragOver={workspaceDrag === null || hoverWorkspace === undefined + ? undefined + : (e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + hoverWorkspace(workspaceGroupHalf(e)) + }} + onDrop={workspaceDrag === null || dropWorkspace === undefined + ? undefined + : (e) => { + e.preventDefault() + dropWorkspace(workspaceGroupHalf(e)) + }} > keys.filter(key => key !== group.key)) } - setExpandedProjects(l => toggled(l, group.key)) + setWorkspaceExpanded(group.key, !group.expanded) }} onCreate={() => { if (group.workspaceId !== undefined) startSession(group.workspaceId) @@ -458,6 +488,8 @@ export function WorkspaceBrowser({ // A flat list has no single Workspace account to drag. Keep the stored // grouped preference intact while presenting the flat list by recency. const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy + // HMR can retain the preceding view-store instance until the slot remounts. + const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -497,12 +529,14 @@ export function WorkspaceBrowser({ useEffect(() => { if (!wide || !searchExpanded) return - const onPointerDown = (event: PointerEvent): void => { + const onClick = (event: MouseEvent): void => { if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return searchInput.current?.blur() + setQuery('') + setSearchExpanded(false) } - document.addEventListener('pointerdown', onPointerDown) - return () => { document.removeEventListener('pointerdown', onPointerDown) } + document.addEventListener('click', onClick) + return () => { document.removeEventListener('click', onClick) } }, [wide, searchExpanded]) useEffect(() => { @@ -810,6 +844,8 @@ export function WorkspaceBrowser({ onSessionArchive={onSessionArchive} forkSession={forkSession} workspaces={workspaces} + workspaceExpansion={workspaceExpansion} + setWorkspaceExpanded={actions.setWorkspaceExpanded} archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 0903bbb600..9f56cda9f9 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -83,6 +83,12 @@ export interface RowDragProps { end: () => void } +/** Drag lifecycle owned by a workspace row; its enclosing group owns hit testing. */ +interface WorkspaceRowDragProps { + start: () => void + end: () => void +} + /** Pointer-position half of a row (insert line above or below). */ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { const rect = e.currentTarget.getBoundingClientRect() @@ -108,7 +114,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ actions?: { rename: () => void; delete: () => void } | undefined /** Present only for real Workspace rows in the grouped view. */ - drag?: RowDragProps | undefined + drag?: WorkspaceRowDragProps | undefined t: RowTranslate }) { const row = group @@ -122,10 +128,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: ] const ownRow = (
{ - if (!drag.active) return - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - drag.hover(rowHalf(e)) - }} - onDrop={drag === undefined - ? undefined - : (e) => { - if (!drag.active) return - e.preventDefault() - drag.drop(rowHalf(e)) - }} > {row.expanded ? : } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index 91abedccfa..9a17ba0c15 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -12,8 +12,13 @@ export type WorkspaceGroupBy = 'workspace' | 'flat' /** Session order: durable Workspace order or a derived timestamp order. */ export type WorkspaceOrderBy = 'manual' | 'created' | 'updated' -/** Workspace browser viewing state; transient expansion facts stay component-local. */ -type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy } +/** Workspace browser viewing state persisted across surface remounts and reloads. */ +type WorkspaceViewState = { + groupBy: WorkspaceGroupBy + orderBy: WorkspaceOrderBy + /** Explicit zero-or-five-session state keyed by Workspace group identity. */ + workspaceExpansion: Record +} /** * Annotation twin of the actions literal below (the export needs a declared @@ -22,6 +27,7 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void + setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void } /** @@ -30,12 +36,12 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual' }), - // The added order field changes the whole-value persistence format. - persist: 'dsh.workspace.view.v2', + init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }), + persist: 'dsh.workspace.view.v3', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, + setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded }, }, }) } From a46a7bf912f371e29801fa88551457ea84baea3f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:10:21 +0800 Subject: [PATCH 07/37] style(client): simplify expanded workspace icon --- .../client/ui-workspace/src/client/rows/Rows.module.css | 4 ---- packages/client/ui-workspace/src/client/rows/Rows.tsx | 7 +++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 0880820e4a..902d8e0327 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -130,10 +130,6 @@ } -.folderActive { - color: var(--dsw-alias-state-business-primary); -} - /* Project leading slot: folder by default, expand arrow on row hover. */ .projectRow .chevron { display: none; } .projectRow:hover .chevron { display: inline-flex; } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 9f56cda9f9..05f36cf959 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -9,7 +9,7 @@ import { useState } from 'react' import clsx from 'clsx' import { HoverCard, IconArchiveOutline20, IconBranchOutline16, IconEditOutline16, - IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + IconEllipsisOutline16, IconFolderClose16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' @@ -120,7 +120,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: const row = group // The ungrouped bucket has no workspace title: its label is dictionary copy. const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label - const active = group.expanded && group.containsCurrent const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, @@ -142,8 +141,8 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: }} onDragEnd={drag?.end} > - - {row.expanded ? : } + + {row.expanded ? : } From 4ce51888be60a65bad565e3cfda15a1cb848471b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:19:51 +0800 Subject: [PATCH 08/37] fix(client): inherit current workspace for new sessions --- .../runtime/src/client/contract/workspaces.ts | 8 +++++--- .../runtime/src/client/workspaces/service.ts | 16 +++++++++++----- .../ui-sidebar/src/client/contract/slots.ts | 4 ++-- packages/client/ui-sidebar/src/client/index.ts | 2 +- .../ui-workspace/src/client/contract/slots.ts | 6 +++--- packages/client/ui-workspace/src/client/index.ts | 4 ++-- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index a541887df0..ff530ab800 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -21,9 +21,11 @@ export interface IWorkspaces { */ connectWorkspace(workspaceId: WorkspaceId): Promise /** - * The New Session flow: connect the target (or recent) Workspace and open - * the resulting session; failures surface on the session list state. - * @param workspaceId - explicit target; omitted uses the recency projection. + * The New Session flow: connect the explicit, current-Session, or recent + * Workspace and open the resulting session; failures surface on the session + * list state. + * @param workspaceId - explicit target; omitted inherits the current + * Session's Workspace before falling back to the recency projection. */ startSession(workspaceId?: WorkspaceId): void /** diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 8b26d0f1b0..527f5ec0ab 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -167,14 +167,20 @@ export class WorkspacesService implements IWorkspaces { /** * The shared New Session action behind the shell entry points (sidebar * button, workspace browser): resolve the target Workspace — explicit wins, - * else the recent-Workspace projection — connect its blank session and - * navigate there; with no Workspace at all, clear the selection into the - * New Session view state. Connect failures are non-fatal (console - * diagnostics; the current view stays usable). + * then the current Session's Workspace, then the recent-Workspace + * projection — connect its blank session and navigate there; with no + * Workspace at all, clear the selection into the New Session view state. + * Connect failures are non-fatal (console diagnostics; the current view + * stays usable). * @param workspaceId - explicit target Workspace for scoped actions. */ startSession(workspaceId?: WorkspaceId): void { - const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId + const workspace = this.list.getSnapshot() + const current = this.sessions.list.getSnapshot().current + const currentWorkspaceId = current === undefined + ? undefined + : workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId + const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId if (target === undefined) { this.sessions.clear() return diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 7b30e4232e..4da4d14eed 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -58,8 +58,8 @@ export interface SidebarSettingsOwnerProps { export type SidebarRootInjected = { /** * Start a New Session: with a workspace, reuse-or-create its blank session - * and open it; without one, clear the selection into the New Session pure - * view state (the conversation.empty seat). + * and open it; without one, inherit the current Session Workspace, then the + * recent Workspace, or clear into the New Session pure view when none exist. */ startSession: (workspaceId?: WorkspaceId) => void /** Toggle the sidebar column through the layout service. */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 3d7ed23aa4..a9706c3e99 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the runtime's shared action - // (recent-Workspace targeting; explicit Workspace wins for scoped actions). + // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e5487d2657..8027a3623a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -91,9 +91,9 @@ export type DirectoryPickingHooks = { */ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** - * Start a New Session in a Workspace: reuse-or-create its blank session - * and open it; with no workspace, clear the selection into the New Session - * pure view state (the conversation.empty seat). + * Start a New Session in a Workspace: reuse-or-create its blank session and + * open it; without an explicit workspace, inherit the current Session + * Workspace, then the recent Workspace, or clear into the New Session view. */ startSession: (workspaceId?: WorkspaceId) => void /** Open a real Session. */ diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 41e88116fc..6b14243ecf 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -68,8 +68,8 @@ export function apply(ctx: ClientContext): void { const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ - // Explicit group actions keep their target; unscoped New Session rides - // the runtime's shared action (recent-Workspace projection inside). + // Explicit group actions keep their target; unscoped New Session inherits + // the current Session Workspace before the recent-Workspace fallback. startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, searchSessions, From d86ecf7b29b71a60c1e3dfe0f8586a4dd2a821eb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:19:55 +0800 Subject: [PATCH 09/37] style(client): collapse search into header action --- .../src/client/WorkspaceBrowser.module.css | 35 ++++++++++++++----- .../src/client/WorkspaceBrowser.tsx | 4 +-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 270b8902d6..944c6917d1 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -70,11 +70,21 @@ .searchSlot { flex: 1; + max-width: 28px; min-width: 0; display: flex; align-items: center; - padding-left: 4px; + margin-left: auto; + padding-left: 0; box-sizing: border-box; + transition: + max-width 180ms var(--ds-ease-in-out), + padding-left 180ms var(--ds-ease-in-out); +} + +.searchSlotExpanded { + max-width: 100%; + padding-left: 4px; } .headerActions { @@ -110,15 +120,15 @@ align-items: center; gap: 0; width: 100%; - height: 26px; + height: 28px; margin: 0; padding: 0; box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l1); - border-radius: 10px; + border: none; + border-radius: 50%; background: transparent; cursor: text; - color: var(--dsw-alias-label-caption); + color: var(--dsw-alias-label-secondary); overflow: hidden; transition: width 180ms var(--ds-ease-in-out), @@ -128,9 +138,12 @@ } .searchExpanded { + height: 26px; padding: 0 4px 0 0; - border-color: var(--dsw-alias-border-l2); + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; background: transparent; + color: var(--dsw-alias-label-caption); } .searchButton { @@ -138,8 +151,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 28px; + height: 28px; border: none; border-radius: 50%; padding: 0; @@ -148,6 +161,11 @@ color: inherit; } +.searchExpanded .searchButton { + width: 26px; + height: 26px; +} + .searchButton:hover { background: var(--dsw-alias-interactive-bg-hover); } @@ -420,6 +438,7 @@ } .search, + .searchSlot, .searchInput, .headerActions { transition: none; diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 016682c0c3..5c27af999f 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -691,7 +691,7 @@ export function WorkspaceBrowser({ )} {wide && ( -
+
- + Date: Tue, 11 Aug 2026 14:50:50 +0800 Subject: [PATCH 10/37] feat(client): refine workspace sidebar interactions --- .../src/client/SettingsRoot.module.css | 10 +- .../src/client/WorkspaceBrowser.module.css | 54 ++++- .../src/client/WorkspaceBrowser.tsx | 194 +++++++++++++++--- .../client/ui-workspace/src/client/locales.ts | 2 - .../src/client/rows/Rows.module.css | 25 ++- .../ui-workspace/src/client/rows/Rows.tsx | 10 +- .../client/ui-workspace/src/client/stores.ts | 32 ++- .../client/ui-workspace/src/client/tree.ts | 14 +- 8 files changed, 267 insertions(+), 74 deletions(-) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index dd3cd9661c..060e8d115b 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -1,6 +1,6 @@ /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar foot trigger row + centered 1080x700 modal panel. The trigger uses the - sidebar's 38px wide row / 36px rail circle rhythm; the + sidebar's 34px compact row / 36px rail circle rhythm; the panel is a two-column layout — 188px nav rail + content column with a 54px header and the 24px-padded options area. */ @@ -10,10 +10,10 @@ display: flex; align-items: center; gap: 8px; - width: calc(100% - 8px); - height: 38px; - margin: 4px 4px 4px; - padding: 8px 2px 8px 10px; + width: calc(100% + 8px); + height: 34px; + margin: 4px -4px 4px; + padding: 6px 2px 6px 10px; box-sizing: border-box; border: none; border-radius: 12px; diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 944c6917d1..1431096afe 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -38,6 +38,19 @@ 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 { @@ -56,6 +69,7 @@ } .root:not(.rail) .sectionHeader { + margin-top: 2px; margin-right: -4px; } @@ -66,6 +80,23 @@ overflow: hidden; white-space: nowrap; line-height: 20px; + opacity: 1; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + margin-right 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; +} + +.sectionLabelHidden { + max-width: 0; + margin-right: -4px; + opacity: 0; + transform: translateX(-4px); + visibility: hidden; + transition-delay: 0s, 0s, 0s, 0s, 180ms; } .searchSlot { @@ -84,7 +115,7 @@ .searchSlotExpanded { max-width: 100%; - padding-left: 4px; + padding-left: 0; } .headerActions { @@ -138,7 +169,9 @@ } .searchExpanded { - height: 26px; + width: calc(100% + 4px); + height: 34px; + margin-inline: -2px; padding: 0 4px 0 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; @@ -162,8 +195,8 @@ } .searchExpanded .searchButton { - width: 26px; - height: 26px; + width: 28px; + height: 34px; } .searchButton:hover { @@ -204,8 +237,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; + width: 24px; + height: 24px; border: none; border-radius: 50%; padding: 0; @@ -214,6 +247,10 @@ color: var(--dsw-alias-label-secondary); } +.clearButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Rail variant (own .rail class from the wide owner prop — the region never reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ @@ -367,7 +404,7 @@ .sessionOverflowButton { width: 100%; - height: 30px; + height: 28px; border: none; border-radius: 8px; padding: 0 12px 0 28px; @@ -385,8 +422,6 @@ .sessionOverflowButton:hover { background: transparent; color: var(--dsw-alias-label-secondary); - text-decoration: underline; - text-underline-offset: 2px; } .empty { @@ -438,6 +473,7 @@ } .search, + .sectionLabel, .searchSlot, .searchInput, .headerActions { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 5c27af999f..f1dd5dd8aa 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -12,11 +12,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCloseFill14, IconPersonalizationOutline16, + Button, IconCheckOutline16, IconCloseFill14, IconPersonalizationOutline16, IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { - SessionSearchResultItem, WorkspaceId, WorkspaceView, + SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from './contract/slots.ts' import type { SessionNode, SessionOrderBy } from './tree.ts' @@ -37,6 +37,8 @@ const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 const EMPTY_WORKSPACE_EXPANSION: Readonly> = Object.freeze({}) +const EMPTY_RECENT_SESSION_ORDER: Readonly> = Object.freeze({}) +const EMPTY_RECENT_SESSION_UPDATED_AT: Readonly>>> = Object.freeze({}) /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -54,6 +56,33 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } +/** Reconcile a stored view order with the Workspace's current session account. */ +function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] { + if (stored === undefined) return [...sessionIds] + const byId = new Map(sessionIds.map(id => [id as string, id])) + const ordered: SessionId[] = [] + const included = new Set() + for (const key of stored) { + const id = byId.get(key) + if (id === undefined || included.has(key)) continue + ordered.push(id) + included.add(key) + } + for (const id of sessionIds) { + if (included.has(id)) continue + ordered.push(id) + } + return ordered +} + +/** Newest update first with stable Session identity as the tie-break. */ +function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number { + const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY + const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY + if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt + return a < b ? -1 : 1 +} + /** Grouping and ordering menu; own open state so it resets with the wide chrome. */ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { groupBy: 'workspace' | 'flat' @@ -63,23 +92,27 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) + const optionLabel = (label: string, selected: boolean) => ( + + {label} + {selected && } + + ) return ( { setOpen(false) }} items={[ { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, - { id: 'workspace', label: t('groupBy.workspace') }, - { id: 'flat', label: t('groupBy.flat') }, + { id: 'workspace', label: optionLabel(t('groupBy.workspace'), groupBy === 'workspace') }, + { id: 'flat', label: optionLabel(t('groupBy.flat'), groupBy === 'flat') }, { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, - { id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' }, - { id: 'created', label: t('orderBy.created') }, - { id: 'updated', label: t('orderBy.updated') }, + { id: 'manual', label: optionLabel(t('orderBy.manual'), orderBy === 'manual'), disabled: groupBy !== 'workspace' }, + { id: 'updated', label: optionLabel(t('orderBy.updated'), orderBy === 'updated') }, ]} - selectedIds={[groupBy, orderBy]} onSelect={(id) => { if (id === 'workspace' || id === 'flat') onGroupPick(id) - else if (id === 'manual' || id === 'created' || id === 'updated') onOrderPick(id) + else if (id === 'manual' || id === 'updated') onOrderPick(id) setOpen(false) }} align="end" @@ -133,6 +166,14 @@ type SessionTreeProps = Pick< workspaceExpansion: Readonly> /** Persist one Workspace group's zero-or-five-session state. */ setWorkspaceExpanded: (key: string, expanded: boolean) => void + /** Editable orders used by recent-update mode. */ + recentSessionOrder: Readonly> + /** Last update timestamps observed by recent-update mode. */ + recentSessionUpdatedAt: Readonly>>> + /** Replace one recent-mode order and its observed timestamps. */ + syncRecentSessions: (workspaceKey: string, order: string[], updatedAt: Record) => void + /** Apply a manual drag inside one recent-mode order. */ + setRecentSessionOrder: (workspaceKey: string, order: string[]) => void /** Registry-global archive set (hidden rows). */ archivedSessionIds: readonly SessionNode['id'][] /** Open the browser-owned rename dialog for a real Workspace group. */ @@ -143,7 +184,7 @@ type SessionTreeProps = Pick< onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void /** Archive a session (row menu action; the row disappears on the state echo). */ onSessionArchive: (sessionId: SessionNode['id']) => void - /** Session visual order; only manual mode exposes durable Session dragging. */ + /** Session visual order; manual mode drags durable order, updated mode drags its view order. */ orderBy: SessionOrderBy } @@ -152,14 +193,34 @@ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, - workspaceExpansion, setWorkspaceExpanded, t, + workspaceExpansion, setWorkspaceExpanded, + recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) - // Transient drag viewing state (never store-bound; order truth stays Host-side). + // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) + const sessionDropCommitted = useRef(false) const [workspaceDrag, setWorkspaceDrag] = useState(null) + const sessionDragging = drag !== null + useEffect(() => { + if (!sessionDragging) return + // Row hover still owns the insertion marker. Accept the native drag at + // document level so releasing outside the list is not rendered as a + // rejected drop before dragend commits that last marker. + const acceptDrag = (event: DragEvent): void => { + event.preventDefault() + if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move' + } + const acceptDrop = (event: DragEvent): void => { event.preventDefault() } + document.addEventListener('dragover', acceptDrag) + document.addEventListener('drop', acceptDrop) + return () => { + document.removeEventListener('dragover', acceptDrag) + document.removeEventListener('drop', acceptDrop) + } + }, [sessionDragging]) const currentGroup = current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) @@ -172,11 +233,79 @@ function SessionTree({ () => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key), [workspaceExpansion], ) + useEffect(() => { + if (orderBy !== 'updated' || list.phase !== 'ready') return + for (const workspace of workspaces) { + const key = workspace.workspaceId as string + const sessionIds = workspace.sessionIds.filter(id => list.byId[id] !== undefined) + const previousOrder = recentSessionOrder[key] + const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {} + let nextOrder = reconciledSessionOrder(sessionIds, previousOrder) + if (previousOrder === undefined) { + nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId)) + } else { + const promoted = sessionIds + .filter((id) => previousUpdatedAt[id] === undefined || list.byId[id]!.updatedAt > previousUpdatedAt[id]!) + .sort((a, b) => compareSessionRecency(a, b, list.byId)) + if (promoted.length > 0) { + const promotedIds = new Set(promoted) + nextOrder = [...promoted, ...nextOrder.filter(id => !promotedIds.has(id))] + } + } + const nextUpdatedAt: Record = {} + for (const id of sessionIds) nextUpdatedAt[id] = list.byId[id]!.updatedAt + const orderChanged = previousOrder === undefined + || nextOrder.length !== previousOrder.length + || nextOrder.some((id, index) => id !== previousOrder[index]) + const timestampsChanged = Object.keys(nextUpdatedAt).length !== Object.keys(previousUpdatedAt).length + || Object.entries(nextUpdatedAt).some(([id, updatedAt]) => previousUpdatedAt[id] !== updatedAt) + if (orderChanged || timestampsChanged) { + syncRecentSessions(key, nextOrder.map(id => id as string), nextUpdatedAt) + } + } + }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, workspaces]) + const orderedWorkspaces = useMemo(() => { + if (orderBy !== 'updated') return workspaces + return workspaces.map((workspace) => { + const stored = recentSessionOrder[workspace.workspaceId as string] + const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored) + if (stored === undefined) sessionIds.sort((a, b) => compareSessionRecency(a, b, list.byId)) + return { ...workspace, sessionIds } + }) + }, [list.byId, orderBy, recentSessionOrder, workspaces]) const groups = useMemo( - () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy), - [list, workspaces, archivedSessionIds, expandedProjects, orderBy], + () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'), + [list, orderedWorkspaces, archivedSessionIds, expandedProjects], ) const now = Date.now() + const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { + if (sessionDropCommitted.current) return + sessionDropCommitted.current = true + setDrag(null) + const group = groups.find(candidate => candidate.workspaceId === activeDrag.workspaceId) + if (group === undefined) return + const targetIndex = group.sessions.findIndex(session => session.id === over.id) + if (targetIndex === -1) return + const anchor = over.half === 'before' ? over.id : group.sessions[targetIndex + 1]?.id + if (anchor === activeDrag.sessionId) return + const sourceIndex = group.sessions.findIndex(session => session.id === activeDrag.sessionId) + const anchorIndex = anchor === undefined + ? group.sessions.length + : group.sessions.findIndex(session => session.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + if (orderBy === 'updated') { + const account = orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceId) + if (account === undefined) return + const nextOrder = account.sessionIds.filter(id => id !== activeDrag.sessionId) + const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) + nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) + setRecentSessionOrder(activeDrag.workspaceId as string, nextOrder.map(id => id as string)) + return + } + insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => { + console.warn('session reorder rejected:', reason) + }) + } return (
@@ -271,14 +400,15 @@ function SessionTree({ {(expandedSessionGroups.includes(group.key) ? group.sessions : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) - ).map((node, index) => { + ).map((node) => { // Draggable: real-workspace session rows. The drag // never leaves its group — rows of other groups show no markers // and reject drops (visual movement confined to this section). - const draggable = group.workspaceId !== undefined && orderBy === 'manual' + const draggable = group.workspaceId !== undefined const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId const dragProps = !draggable || group.workspaceId === undefined ? undefined : { start: () => { + sessionDropCommitted.current = false setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) }, active: sameGroupDrag, @@ -290,21 +420,13 @@ function SessionTree({ drop: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ if (drag === null) return - const sessions = group.sessions - // Anchor = the row the insert line points at ('after' means - // the next root; end-of-list omits the anchor → append). - const anchor = half === 'before' ? node.id : sessions[index + 1]?.id - setDrag(null) - if (anchor === drag.sessionId) return - // No-op when the drop lands back on the source position. - const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId) - const anchorIndex = anchor === undefined ? sessions.length : sessions.findIndex(r => r.id === anchor) - if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return - insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => { - console.warn('session reorder rejected:', reason) - }) + commitSessionDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) + else setDrag(null) + sessionDropCommitted.current = false }, - end: () => { setDrag(null) }, } return ( s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) + const recentSessionOrder = useStore(s => s.recentSessionOrder ?? EMPTY_RECENT_SESSION_ORDER) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt ?? EMPTY_RECENT_SESSION_UPDATED_AT) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -532,12 +656,12 @@ export function WorkspaceBrowser({ const onClick = (event: MouseEvent): void => { if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return searchInput.current?.blur() - setQuery('') + if (query !== '') return setSearchExpanded(false) } document.addEventListener('click', onClick) return () => { document.removeEventListener('click', onClick) } - }, [wide, searchExpanded]) + }, [query, wide, searchExpanded]) useEffect(() => { if (normalizedQuery === '') { @@ -686,7 +810,7 @@ export function WorkspaceBrowser({
{wide && ( - + {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} @@ -846,6 +970,10 @@ export function WorkspaceBrowser({ workspaces={workspaces} workspaceExpansion={workspaceExpansion} setWorkspaceExpanded={actions.setWorkspaceExpanded} + recentSessionOrder={recentSessionOrder} + recentSessionUpdatedAt={recentSessionUpdatedAt} + syncRecentSessions={actions.syncRecentSessions} + setRecentSessionOrder={actions.setRecentSessionOrder} archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index c816326157..e43c977a40 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -15,7 +15,6 @@ export const zh = { 'groupBy.flat': '单列表', 'orderBy.label': '排序方式', 'orderBy.manual': '手动排序', - 'orderBy.created': '创建时间', 'orderBy.updated': '最近更新', 'sessions.expand': '展开其余 {n} 个会话', 'sessions.collapse': '收起', @@ -84,7 +83,6 @@ export const en = { 'groupBy.flat': 'In one list', 'orderBy.label': 'Order by', 'orderBy.manual': 'Manual', - 'orderBy.created': 'Date created', 'orderBy.updated': 'Last updated', 'sessions.expand': 'Show {n} more sessions', 'sessions.collapse': 'Show less', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 902d8e0327..548407fd40 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -1,5 +1,5 @@ -/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px - single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps +/* Tree rows: project 34px, session 32px, radius 8, indent step 22px + (16px slot + 6px gap). Hover swaps are pure CSS: project folder -> chevron + action buttons; session time -> ellipsis button. */ @@ -29,11 +29,11 @@ flex-direction: column; align-items: stretch; width: 100%; - min-height: 62px; + min-height: 48px; box-sizing: border-box; border: none; border-radius: 8px; - padding: 7px 8px; + padding: 4px 8px; background: transparent; cursor: pointer; text-align: left; @@ -64,9 +64,16 @@ line-height: 20px; } +.searchResultMeta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + margin-left: 20px; +} + .searchResultWorkspace, .searchResultSnippet { - margin-left: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -75,16 +82,20 @@ } .searchResultWorkspace { + flex: none; + max-width: 40%; color: var(--dsw-alias-label-tertiary); } .searchResultSnippet { + flex: 1; + min-width: 0; color: var(--dsw-alias-label-secondary); } /* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 36px; + height: 34px; align-items: center; box-sizing: border-box; } @@ -95,7 +106,7 @@ /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ .sessionRow { - height: 34px; + height: 32px; gap: 0; /* Mount fade: session rows appear by unfolding a group (or the tree mounting). Stable row keys keep already-visible rows from replaying it. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 05f36cf959..71998f2b6b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -317,10 +317,12 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {result.title} - {result.workspace} - {result.snippet !== undefined && ( - {result.snippet} - )} + + {result.workspace} + {result.snippet !== undefined && ( + {result.snippet} + )} + ) } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index 9a17ba0c15..e5334b0e4f 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -9,8 +9,8 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run /** Session-list grouping mode: workspace sections or one flat recency list. */ export type WorkspaceGroupBy = 'workspace' | 'flat' -/** Session order: durable Workspace order or a derived timestamp order. */ -export type WorkspaceOrderBy = 'manual' | 'created' | 'updated' +/** Session order: durable Workspace order or an activity-promoted editable order. */ +export type WorkspaceOrderBy = 'manual' | 'updated' /** Workspace browser viewing state persisted across surface remounts and reloads. */ type WorkspaceViewState = { @@ -18,6 +18,10 @@ type WorkspaceViewState = { orderBy: WorkspaceOrderBy /** Explicit zero-or-five-session state keyed by Workspace group identity. */ workspaceExpansion: Record + /** Editable per-Workspace order used by recent-update mode. */ + recentSessionOrder: Record + /** Last observed update timestamps used to detect promotion events. */ + recentSessionUpdatedAt: Record> } /** @@ -28,6 +32,13 @@ type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void + syncRecentSessions: ( + draft: WorkspaceViewState, + workspaceKey: string, + order: string[], + updatedAt: Record, + ) => void + setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void } /** @@ -36,12 +47,25 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }), - persist: 'dsh.workspace.view.v3', + init: (): WorkspaceViewState => ({ + groupBy: 'workspace', + orderBy: 'manual', + workspaceExpansion: {}, + recentSessionOrder: {}, + recentSessionUpdatedAt: {}, + }), + persist: 'dsh.workspace.view.v4', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded }, + syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record) => { + d.recentSessionOrder[workspaceKey] = order + d.recentSessionUpdatedAt[workspaceKey] = updatedAt + }, + setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => { + d.recentSessionOrder[workspaceKey] = order + }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 5120e31931..553742ef90 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -34,7 +34,7 @@ export interface SessionNode { } /** Session order selected by the Workspace browser. */ -export type SessionOrderBy = 'manual' | 'created' | 'updated' +export type SessionOrderBy = 'manual' | 'updated' /** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { @@ -108,14 +108,8 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } -/** Newest-created first, id as the deterministic tiebreak. */ -function byCreation(a: SessionSummary, b: SessionSummary): number { - if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt - return a.id < b.id ? -1 : 1 -} - -function sortSessions(sessions: SessionSummary[], orderBy: Exclude): void { - sessions.sort(orderBy === 'created' ? byCreation : byRecency) +function sortSessions(sessions: SessionSummary[]): void { + sessions.sort(byRecency) } /** @@ -150,7 +144,7 @@ function buildGroup( orderBy: SessionOrderBy, ): Group { const sessions = [...members] - if (orderBy !== 'manual') sortSessions(sessions, orderBy) + if (orderBy !== 'manual') sortSessions(sessions) return { key, workspaceId, cwd, createdAt, label, sessions } } From 23889483cf90104fac0b71c47b5c12349702b3eb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:50:57 +0800 Subject: [PATCH 11/37] style(client): refine conversation tab indicator --- .../skeleton/ConversationRoot.module.css | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 971661cd48..0a69ec6e72 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -26,9 +26,22 @@ } .header { + position: relative; flex: none; padding: 12px 28px 0 20px; - border-bottom: 1px solid var(--dsw-alias-border-l2); + border-bottom: 1px solid transparent; +} + +.header::after { + content: ''; + position: absolute; + right: 0; + bottom: 1px; + left: 0; + z-index: 0; + height: 1px; + background: var(--dsw-alias-border-l2); + pointer-events: none; } /* Blank hero/settling: keep the strict Session header mounted without taking @@ -100,13 +113,15 @@ /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ .tabs { + position: relative; + z-index: 1; display: flex; gap: 36px; margin-top: 4px; padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar. */ .tab { position: relative; padding: 0 0 11px; @@ -126,6 +141,7 @@ bottom: 0; left: 0; height: 3px; + border-radius: 2px; background: transparent; } From c4affa852c2bed4f5bf3d79291d143a74a9d3cfb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:55:20 +0800 Subject: [PATCH 12/37] style(client): reduce conversation tab indicator --- .../src/client/skeleton/ConversationRoot.module.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 0a69ec6e72..473e2a1fd7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -1,7 +1,7 @@ /* Conversation column skeleton: header (breadcrumb row only for subagents not fork + tabs) over the view area, composer InputBar at the bottom. Column width/squeeze is layout's; this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with - a 3px active bar. */ + a 2px active bar. */ .root { display: flex; @@ -121,7 +121,7 @@ padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar. */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 2px bar. */ .tab { position: relative; padding: 0 0 11px; @@ -138,9 +138,9 @@ content: ''; position: absolute; right: 0; - bottom: 0; + bottom: 1px; left: 0; - height: 3px; + height: 2px; border-radius: 2px; background: transparent; } From 8005ab78bd3bf2243e82c243b720389e367d76a7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:24:32 +0800 Subject: [PATCH 13/37] fix(client): keep flat sessions ordered by recency --- .../src/client/WorkspaceBrowser.tsx | 235 +++++++++--------- .../client/ui-workspace/src/client/tree.ts | 8 +- 2 files changed, 122 insertions(+), 121 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index f1dd5dd8aa..637b89c162 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -36,9 +36,6 @@ const SEARCH_DEBOUNCE_MS = 250 const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 -const EMPTY_WORKSPACE_EXPANSION: Readonly> = Object.freeze({}) -const EMPTY_RECENT_SESSION_ORDER: Readonly> = Object.freeze({}) -const EMPTY_RECENT_SESSION_UPDATED_AT: Readonly>>> = Object.freeze({}) /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -245,7 +242,11 @@ function SessionTree({ nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId)) } else { const promoted = sessionIds - .filter((id) => previousUpdatedAt[id] === undefined || list.byId[id]!.updatedAt > previousUpdatedAt[id]!) + .filter((id) => { + const session = list.byId[id] + return session !== undefined + && (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id]) + }) .sort((a, b) => compareSessionRecency(a, b, list.byId)) if (promoted.length > 0) { const promotedIds = new Set(promoted) @@ -253,7 +254,10 @@ function SessionTree({ } } const nextUpdatedAt: Record = {} - for (const id of sessionIds) nextUpdatedAt[id] = list.byId[id]!.updatedAt + for (const id of sessionIds) { + const session = list.byId[id] + if (session !== undefined) nextUpdatedAt[id] = session.updatedAt + } const orderChanged = previousOrder === undefined || nextOrder.length !== previousOrder.length || nextOrder.some((id, index) => id !== previousOrder[index]) @@ -299,7 +303,7 @@ function SessionTree({ const nextOrder = account.sessionIds.filter(id => id !== activeDrag.sessionId) const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) - setRecentSessionOrder(activeDrag.workspaceId as string, nextOrder.map(id => id as string)) + setRecentSessionOrder(activeDrag.workspaceId, nextOrder.map(id => id as string)) return } insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => { @@ -350,112 +354,112 @@ function SessionTree({ // Group section: header row + expanded top-level session rows. The // inter-group breathing room is the section's own margin // (WorkspaceBrowser.module.css). -
{ - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - hoverWorkspace(workspaceGroupHalf(e)) - }} - onDrop={workspaceDrag === null || dropWorkspace === undefined - ? undefined - : (e) => { - e.preventDefault() - dropWorkspace(workspaceGroupHalf(e)) - }} - > - { - if (group.expanded) { - setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) - } - setWorkspaceExpanded(group.key, !group.expanded) - }} - onCreate={() => { - if (group.workspaceId !== undefined) startSession(group.workspaceId) - }} - drag={workspaceDragProps} - actions={group.workspaceId === undefined +
{ - /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ - if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) - }, - delete: () => { - /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ - if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) - }, + : (e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + hoverWorkspace(workspaceGroupHalf(e)) }} - /> - {(expandedSessionGroups.includes(group.key) - ? group.sessions - : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) - ).map((node) => { + onDrop={workspaceDrag === null || dropWorkspace === undefined + ? undefined + : (e) => { + e.preventDefault() + dropWorkspace(workspaceGroupHalf(e)) + }} + > + { + if (group.expanded) { + setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) + } + setWorkspaceExpanded(group.key, !group.expanded) + }} + onCreate={() => { + if (group.workspaceId !== undefined) startSession(group.workspaceId) + }} + drag={workspaceDragProps} + actions={group.workspaceId === undefined + ? undefined + : { + rename: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }, + delete: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) + }, + }} + /> + {(expandedSessionGroups.includes(group.key) + ? group.sessions + : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) + ).map((node) => { // Draggable: real-workspace session rows. The drag // never leaves its group — rows of other groups show no markers // and reject drops (visual movement confined to this section). - const draggable = group.workspaceId !== undefined - const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId - const dragProps = !draggable || group.workspaceId === undefined ? undefined : { - start: () => { - sessionDropCommitted.current = false - setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) - }, - active: sameGroupDrag, - marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, - hover: (half: 'before' | 'after') => { + const draggable = group.workspaceId !== undefined + const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId + const dragProps = !draggable || group.workspaceId === undefined ? undefined : { + start: () => { + sessionDropCommitted.current = false + setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) + }, + active: sameGroupDrag, + marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, + hover: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ - setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) - }, - drop: (half: 'before' | 'after') => { + setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) + }, + drop: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ - if (drag === null) return - commitSessionDrag(drag, { id: node.id, half }) - }, - end: () => { - if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) - else setDrag(null) - sessionDropCommitted.current = false - }, - } - return ( - - ) - })} - {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( - - )} -
+ if (drag === null) return + commitSessionDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) + else setDrag(null) + sessionDropCommitted.current = false + }, + } + return ( + + ) + })} + {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( + + )} +
) })}
@@ -465,13 +469,13 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, orderBy, t }: Pick< - SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 'orderBy' | 't' +function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< + SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' >) { const list = useSessions(s => s) const rows = useMemo( - () => deriveFlat(list, archivedSessionIds, orderBy), - [list, archivedSessionIds, orderBy], + () => deriveFlat(list, archivedSessionIds), + [list, archivedSessionIds], ) const now = Date.now() return ( @@ -604,16 +608,13 @@ export function WorkspaceBrowser({ // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) - // A live HMR handoff can retain the pre-ordering store instance until the - // slot is remounted; manual is the established Workspace order. - const orderBy = useStore(s => s.orderBy ?? 'manual') + const orderBy = useStore(s => s.orderBy) // A flat list has no single Workspace account to drag. Keep the stored // grouped preference intact while presenting the flat list by recency. const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy - // HMR can retain the preceding view-store instance until the slot remounts. - const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) - const recentSessionOrder = useStore(s => s.recentSessionOrder ?? EMPTY_RECENT_SESSION_ORDER) - const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt ?? EMPTY_RECENT_SESSION_UPDATED_AT) + const workspaceExpansion = useStore(s => s.workspaceExpansion) + const recentSessionOrder = useStore(s => s.recentSessionOrder) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -958,7 +959,7 @@ export function WorkspaceBrowser({ ) : ( diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 553742ef90..292b574b2f 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -208,8 +208,8 @@ function sessionNode( /** * Derive the workspace browser groups with every session as a top-level row. * - * Every group shows; sessions populate under expanded groups, preserving - * Host account order. Blank sessions are excluded except for the selected + * Every group shows; sessions populate under expanded groups in the selected + * local order. Blank sessions are excluded except for the selected * provisional New Session row; archived sessions are excluded everywhere. * Content search lives outside this derivation * (see {@link deriveSearchResults}). @@ -217,6 +217,7 @@ 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( @@ -263,7 +264,6 @@ export function deriveGroups( export function deriveFlat( list: SessionListState, archivedSessionIds: readonly SessionId[], - orderBy: SessionOrderBy = 'updated', ): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) @@ -273,7 +273,7 @@ export function deriveFlat( if (s === undefined || !sessionVisible(s, list.current, archived)) continue rows.push(s) } - sortSessions(rows, orderBy === 'manual' ? 'updated' : orderBy) + sortSessions(rows) return rows.map(session => sessionNode(session, descendants)) } From 6e303ac0b72036c385f66bab835d828f47d27201 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:24:40 +0800 Subject: [PATCH 14/37] test(web): cover workspace sidebar behavior --- apps/web/tests/sidebar-scrollbar.e2e.ts | 12 +- packages/client/connection/tests/fake-api.ts | 5 +- packages/client/runtime/tests/fake-api.ts | 5 + .../runtime/tests/subagent-lineage.spec.ts | 2 +- .../runtime/tests/workspaces-service.spec.ts | 104 ++++++++++++- packages/client/test-runtime/src/sessions.ts | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 4 +- .../tests/conversation-ui.spec.tsx | 2 + .../ui-tool/tests/chat-code-subcalls.spec.tsx | 2 +- .../ui-tool/tests/coverage-tails.spec.tsx | 2 +- .../client/ui-tool/tests/diff-card.spec.tsx | 4 +- .../client/ui-tool/tests/read-card.spec.tsx | 4 +- .../ui-tool/tests/terminal-card.spec.tsx | 4 +- .../ui-workspace/tests/browser-styles.spec.ts | 24 ++- .../client/ui-workspace/tests/rows.spec.tsx | 27 ++-- .../client/ui-workspace/tests/tree.spec.ts | 16 +- .../tests/workspace-browser.spec.tsx | 143 +++++++++++++++++- .../tests/api-proxy-workspace.spec.ts | 42 +++++ .../apiproxy/tests/client-handler.spec.ts | 1 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 12 ++ .../workspace/tests/workspace.spec.ts | 49 +++++- 22 files changed, 420 insertions(+), 48 deletions(-) diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index d5afd925a3..16e98a356e 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -349,9 +349,9 @@ async function pointAt(page: Page, where: 'list' | 'away'): Promise { /** * 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 { 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`) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index f1e62c618a..be821168eb 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -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' }, }))), diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index da3c1d3025..839b5c957c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -174,6 +174,9 @@ export class FakeApiClient implements IApiClient { onWorkspaceDelete: (payload: unknown) => Promise> = () => Promise.resolve(ok({ deleted: true })) + onWorkspaceInsertBefore: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspaceIds: [] })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => 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) => diff --git a/packages/client/runtime/tests/subagent-lineage.spec.ts b/packages/client/runtime/tests/subagent-lineage.spec.ts index 05881576bf..9fe99a073f 100644 --- a/packages/client/runtime/tests/subagent-lineage.spec.ts +++ b/packages/client/runtime/tests/subagent-lineage.spec.ts @@ -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 }), } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index aa0f404da6..2c4ed551d8 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -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>>() 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>>() + 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>>() @@ -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(() => {}) + 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() diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index fc41c83975..ece67e1acd 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -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, } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index be5cd3be28..9858fdaeb9 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -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 diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index ebc140405e..50ec3a6df1 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -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, } } diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 5939655118..4376694492 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -110,7 +110,7 @@ async function bench(snapshot: ConversationSnapshot) { const session = createSnapshotStore(snapshot) const list = createSnapshotStore({ 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, }) diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.spec.tsx index 8010fb8763..06935e0e26 100644 --- a/packages/client/ui-tool/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.spec.tsx @@ -26,7 +26,7 @@ function listStore() { return createSnapshotStore({ 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', diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 1726c81136..92aab13d8c 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -156,7 +156,7 @@ describe('chat row diff body', () => { describe('FileMutationRow diff card', () => { const list = () => createSnapshotStore({ 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: {}, diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index ae719173d5..bf43507bb3 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -170,7 +170,7 @@ describe('GenericToolCard read body', () => { describe('ReadRow keyed toolview', () => { const list = () => createSnapshotStore({ 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: {}, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index dd39b8bc88..334da7f607 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -345,7 +345,7 @@ describe('chat row terminal body', () => { describe('BashRow terminal card', () => { const list = () => createSnapshotStore({ 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: {}, diff --git a/packages/client/ui-workspace/tests/browser-styles.spec.ts b/packages/client/ui-workspace/tests/browser-styles.spec.ts index 4165971bff..86abd521bc 100644 --- a/packages/client/ui-workspace/tests/browser-styles.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.spec.ts @@ -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 | undefined { - const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ') +function declarationsFrom(source: string, selector: string): Map | undefined { + const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, ' ') + const found = new Map() for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue - const found = new Map() 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 | undefined => declarationsFrom(css, selector) +const rowDeclarations = (selector: string): Map | 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)') + }) }) diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 7e0971cf72..edb60c23bb 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -46,7 +46,7 @@ function installClipboard(writeText: (text: string) => Promise): () => 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() - 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', () => { { 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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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() @@ -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( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 5a6d145e36..4ee5c19561 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -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 } }, + }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 918a3f6c0e..ca48733dda 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -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 => ({ - 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 => ({ 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 { + return { effectAllowed: 'uninitialized', dropEffect: 'none', setData: vi.fn() } +} + function mount(overrides: Partial = {}) { const store = createWorkspaceViewStore().create() const props: WorkspaceBrowserProps = { @@ -71,6 +75,7 @@ function mount(overrides: Partial = {}) { 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('搜索名称、关键词…') 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('搜索名称、关键词…') + 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)) }) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index a8f12641da..1b98fedac5 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -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> = + 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() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 9365006de4..9fd5b6b18d 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -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] }), }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ed286334a8..a73064536e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -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, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 789639b70d..1278e30456 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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() diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 3c4b6185fb..c04980eecd 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -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') From 3f09330f4beda54a0df715866d7576cef2f20ea0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:24:56 +0800 Subject: [PATCH 15/37] docs(web): record workspace sidebar behavior --- ...n-list-browsing-and-manual-order.i18n.yaml | 4 +- ...-session-list-browsing-and-manual-order.md | 4 +- ...ssion-list-browsing-and-manual-order.zh.md | 4 +- ...-07-25-workspace-ui-product-flow.i18n.yaml | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 15 +++--- ...2026-07-25-workspace-ui-product-flow.zh.md | 15 +++--- ...kspace-sidebar-order-and-folding.i18n.yaml | 6 +++ ...-11-workspace-sidebar-order-and-folding.md | 54 +++++++++++++++++++ ...-workspace-sidebar-order-and-folding.zh.md | 54 +++++++++++++++++++ docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 11 +++- docs/subsystems/workspace.zh.md | 11 +++- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 4 +- packages/client/runtime/README.zh.md | 4 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 8 +-- packages/client/ui-sidebar/README.zh.md | 8 +-- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../tool-cordis/src/api-catalog.ts | 4 ++ packages/workspace/workspace/README.i18n.yaml | 4 +- packages/workspace/workspace/README.md | 3 +- packages/workspace/workspace/README.zh.md | 3 +- 28 files changed, 198 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index 097c0c9f2d..015852b082 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: 3d2125bdf67a70a1a5bca43c5d5acb09fda178b7 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 161ebd2857073d4dd9cfc2883880cd3e2d91c040 +2026-07-25-session-list-browsing-and-manual-order.md: 52a0fe0c94106cb4178c57e737b1c9a3f458f803 +2026-07-25-session-list-browsing-and-manual-order.zh.md: a6c44579c685479ca460da8e52ea885f20e4776b diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 3d2125bdf6..52a0fe0c94 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -14,7 +14,7 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a ### Flat rows and viewing state -The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. +The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md) later added a browser-local recent-update view without changing the Host account's manual-order authority. ### Row interactions @@ -50,7 +50,7 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, ## Consequences -- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- Manual order is the sole authority over the Host workspace account: activity never mutates `WorkspaceView.sessionIds`. A later browser-local recent-update view may promote active rows without changing that account; its separate semantics are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). - The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. - Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. - Wiring session Delete and growing the wire status enum remain future iterations. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 161ebd2857..a6c44579c6 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 平铺行与浏览态 -group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 +group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。[Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)随后加入浏览器本地的最近更新视图,而未改变 Host 记账的手动顺序权威。 ### 行交互 @@ -50,7 +50,7 @@ ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settin ## Consequences -- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 约定随之改为手动序措辞。 +- 手动序是 Host workspace 账本的唯一顺序权威:活动绝不改动 `WorkspaceView.sessionIds`。后续加入的浏览器本地最近更新视图可以把活跃行提到最前,但不会改变该账本;其独立语义见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 - 壳/区域两事实约定把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 - 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 - session Delete 的功能接线与状态枚举扩 wire,留待后续迭代。 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index c0813607d3..8d8d36e87d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 98e963195126df2ec8291a11b3d9fc7a2baeb0df -2026-07-25-workspace-ui-product-flow.zh.md: 486093be0b8d10c2ae0b8083b305ecad5386351c +2026-07-25-workspace-ui-product-flow.md: 76d279bf2101d7487fe4f5231c7cea4809e166f4 +2026-07-25-workspace-ui-product-flow.zh.md: e15ead7b437d8f2324f7ea51222eb4fcfb4a9e4a diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 98e9631951..76d279bf21 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -20,6 +20,7 @@ The Host provides the following GUI wiring on the Workspace entity: | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | Moves one Workspace within durable registry order and returns the complete committed order | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | @@ -49,7 +50,7 @@ On initial entry, the application waits until both the Workspace and Session bas When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. -Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the current Session's Workspace, then the most recent Workspace, and enters the blank New Session page when no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. @@ -67,11 +68,11 @@ Lost RPC responses, Host frames arriving before completions, and completions arr ### Sidebar and ordering -Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups. +Workspace groups follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and `workspace.insertBefore` durably applies user drag order. Session activity does not move Workspace groups. -Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration. +The Host account remains the manual `Workspace.sessionIds` order: a newly attached Session is placed first and activity does not mutate it. The grouped browser can instead select a browser-local recent-update view that promotes a Session when its `updatedAt` advances and remains manually editable. Five Sessions are visible per open Workspace until the user transiently expands the remainder. The durable Workspace reorder and browser-local Session order are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). -A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows. +The current blank Session appears as a “New session” row without a count, time label, or row menu; other blank Sessions remain hidden and eligible for per-Workspace reuse. Search excludes blank rows. Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. @@ -105,15 +106,15 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer. - The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId. - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. -- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. -- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. +- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered by hydration or Session activity, and explicit Workspace drag order survives reconnect. +- The current blank Session can appear as a single New Session row without exposing other reusable blanks or a Session count. - The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences -- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward. +- SessionHeader does not record last-active time, so historical bootstrap can initialize the Host manual order only by `createdAt`; the browser's optional recent-update view begins from Session summaries after hydration. - Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point. - Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract. - Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 486093be0b..e15ead7b43 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -20,6 +20,7 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | 在持久注册表顺序内移动一个 Workspace,并返回完整的已提交顺序 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | @@ -49,7 +50,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 -顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时先使用当前 Session 所属 Workspace,再使用最近 Workspace;没有真实 Workspace 时进入空白 New Session 页面。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 @@ -67,11 +68,11 @@ RPC 响应丢失、Host frame 先于 completion 和 completion 先于 Host frame ### Sidebar 与排序 -Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。 +Workspace 组使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位,`workspace.insertBefore` 则持久应用用户拖拽顺序;Session 活跃不会移动 Workspace 组。 -组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。 +Host 记账保持手动的 `Workspace.sessionIds` 顺序:新 attach 的 Session 放在首位,活动不会改动该顺序。分组浏览器可以改选浏览器本地的最近更新视图;当 Session 的 `updatedAt` 增大时该视图会把它移到首位,同时仍允许手动调整。每个打开的 Workspace 默认显示五条 Session,用户可临时展开其余条目。持久 Workspace 重排序和浏览器本地 Session 顺序见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 -前端 Session Intent 只有在目标是真实 Workspace 时才作为 「New session」 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。 +当前空白 Session 会显示为一条「New session」行,但不显示数量、时间标签或行菜单;其他空白 Session 保持隐藏,并可由对应 Workspace 复用。搜索会排除空白行。 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 @@ -105,15 +106,15 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。 - 首发按 Workspace、Session、提示词顺序推进,各成功阶段不回滚,输入在提示词被接受前不丢失,创建重试使用同一 SessionId。 - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 -- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 -- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃重排,显式 Workspace 拖拽顺序在重连后仍然保持。 +- 当前空白 Session 可显示为唯一的 New Session 行,同时不暴露其他可复用空白会话,也不显示 Session 数量。 - UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences -- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。 +- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化 Host 手动顺序;浏览器可选的最近更新视图在 hydration 后从 Session 摘要开始建立。 - 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。 - 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 约定。 - 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml new file mode 100644 index 0000000000..a389aedae9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +2026-08-11-workspace-sidebar-order-and-folding.md: 799c972ead9ac5d56fa70d2f6eedda58986ab65e +2026-08-11-workspace-sidebar-order-and-folding.zh.md: 7ac93e08aeaea5ddd455f5cde0395c667a38cca1 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md new file mode 100644 index 0000000000..799c972ead --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -0,0 +1,54 @@ +# Agent Note: Workspace Sidebar Order and Folding + +Status: implemented + +English | [中文](2026-08-11-workspace-sidebar-order-and-folding.zh.md) + +## Problem + +A Workspace with many Sessions can consume the entire sidebar and push other Workspaces out of reach. A compact list needs a bounded default while preserving an explicit route to every Session. The sidebar also needs an activity-oriented order, but `WorkspaceView.sessionIds` is the durable manual account and must not be rewritten by Session activity. + +Workspace groups themselves had no user-controlled durable order. Browser-native drag additionally rejects a drop released outside the list and animates the row back even when the application still has a valid insertion marker. Expanded Workspace sections make header-only hit testing ambiguous because the visual boundary between two groups does not match either header's midpoint. + +## Decision + +### Workspace order + +The Workspace registry owns a durable `workspaceIds` order and exposes `insertBefore(id, beforeId?)` with DOM `insertBefore` semantics. The Host RPC `workspace.insertBefore` returns the complete committed order, and a pure order mutation emits `host/workspace-order-changed` with the same complete order. Unknown source or anchor ids reject as `workspace-not-found`; self-anchored and already-positioned moves do not write. + +The client installs a Workspace drag optimistically. Request and frame generations ensure that only the latest unary echo can replace local order and that a newer Host frame outranks an older response; a latest rejected request restores the preceding order. Every successful list baseline restores Host order so reconnects adopt durable changes made elsewhere. + +### Session folding and view order + +Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five. When more Sessions exist, **Show more** reveals the remainder only for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to five. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. + +The combined view menu offers **Manual** and **Last updated**. Manual follows the Host account in `WorkspaceView.sessionIds`. Last updated maintains a browser-local per-Workspace order that users may still edit by dragging; whenever a Session summary's `updatedAt` advances, that Session is promoted to the front. This view order never writes the Host Session account. The flat list uses recent-update order because it has no single Workspace account for durable Session drag. + +### Drag and compact chrome + +Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line that does not affect layout. During a Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker. + +Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses an empty search but retains a non-empty query. Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances. + +## Alternatives considered + +**Persist the recent-update view into `Workspace.sessionIds`.** Activity would overwrite a deliberate manual order and recreate two competing meanings for the same Host field. + +**Always show every Session in an open Workspace.** One large Workspace would continue to crowd out the rest, and remembering only the whole-group open state would not bound its height. + +**Persist the expanded-remainder state.** A Workspace reopened much later could unexpectedly occupy the full sidebar. Only the zero-or-five state represents a stable navigation preference; revealing the remainder is a local inspection. + +**Use numeric drop indices or header-only hit testing.** Indices drift when rows change during a drag, while header midpoints disagree with the visible boundary when a Workspace is expanded. Anchor ids and full-section geometry remain stable under both conditions. + +**Let the browser reject an outside release.** The application would commit the last valid marker while the browser displays a rejected-drop animation, presenting contradictory feedback. + +## Consequences + +- Workspace order is durable and shared through the Host, while grouping, open state, recent-update Session order, and query state remain browser-local presentation preferences. +- Recent-update mode preserves manual edits until a Session becomes active again; a newer `updatedAt` intentionally promotes that Session to the front. +- Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture. +- The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md). + +## Testing + +Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, and order frames. Runtime tests cover optimistic order, frame/response precedence, rejection rollback, reconnect baselines, and New Session target priority. UI tests cover five-row folding, transient expansion reset, recent-update promotion with manual drag, selected view indicators, expanded-section Workspace hit testing, outside-list Session drops, search collapse rules, and compact CSS dimensions. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md new file mode 100644 index 0000000000..7ac93e08ae --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -0,0 +1,54 @@ +# Agent Note: Workspace 侧边栏顺序与折叠 + +Status: implemented + +[English](2026-08-11-workspace-sidebar-order-and-folding.md) | 中文 + +## 问题 + +Session 很多的 Workspace 会占满整个侧边栏,把其他 Workspace 挤出可见范围。紧凑列表需要有界的默认高度,同时仍要提供到达每条 Session 的明确入口。侧边栏还需要面向活动时间的顺序,但 `WorkspaceView.sessionIds` 是持久的手动记账,不能被 Session 活动改写。 + +Workspace 分组本身没有用户可控的持久顺序。浏览器原生拖拽还会把列表外松手判为拒绝,并把行弹回原位,即使应用仍持有有效插入标记。Workspace 展开后,若只按组头命中,两个分组之间的视觉边界也不再等于任一组头的中点。 + +## 决策 + +### Workspace 顺序 + +Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `insertBefore` 语义的 `insertBefore(id, beforeId?)`。Host RPC `workspace.insertBefore` 返回完整的已提交顺序;单纯顺序变更通过 `host/workspace-order-changed` 推送同一份完整顺序。未知来源或锚点 id 以 `workspace-not-found` 拒绝;以自身为锚点或移动到当前位置不会写入。 + +客户端对 Workspace 拖拽进行乐观安装。请求代次与帧代次保证只有最新一元回声可以替换本地顺序,且更新的 Host 帧优先于旧响应;最新请求被拒时恢复此前顺序。每次成功的列表基线都会恢复 Host 顺序,因此重连会接纳其他位置提交的持久变更。 + +### Session 折叠与视图顺序 + +每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条。存在更多 Session 时,**展开其余**只在当前挂载期间显示剩余项;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为五条。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。 + +组合视图菜单提供**手动排序**和**最近更新**。手动排序遵循 `WorkspaceView.sessionIds` 中的 Host 记账。最近更新为每个 Workspace 维护一份浏览器本地顺序,用户仍可通过拖拽编辑;每当 Session 摘要的 `updatedAt` 增大时,该 Session 会被移到最前。此视图顺序绝不写入 Host Session 记账。平铺列表使用最近更新顺序,因为它没有可承载持久 Session 拖拽的单一 Workspace 记账。 + +### 拖拽与紧凑界面 + +Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条不影响布局的绝对定位横线。Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。 + +搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。点击外部会收起空搜索,但保留非空查询。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。 + +## 考虑过的替代方案 + +**把最近更新视图持久化到 `Workspace.sessionIds`。** 活动会覆盖用户明确安排的手动顺序,并让同一 Host 字段重新承担两种相互竞争的含义。 + +**打开 Workspace 时始终显示全部 Session。** 大型 Workspace 仍会挤占其他分组;只记忆整个分组的打开状态无法限制其高度。 + +**持久化展开剩余状态。** 很久以后重新打开 Workspace 时,它可能意外占满侧边栏。只有零条或五条状态属于稳定导航偏好;显示剩余项只是一次本地查看。 + +**使用数字下标或只按组头命中拖拽。** 拖拽期间行发生变化会使下标漂移;Workspace 展开时,组头中点与可见边界不一致。锚点 id 与完整区段几何在两种情况下都保持稳定。 + +**让浏览器拒绝列表外松手。** 应用会提交最后一个有效标记,而浏览器同时播放拒绝动画,形成相互矛盾的反馈。 + +## 后果 + +- Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、最近更新 Session 顺序和查询状态仍是浏览器本地呈现偏好。 +- 最近更新模式会保持手动调整,直到某条 Session 再次活跃;更大的 `updatedAt` 会有意把它移到最前。 +- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。 +- Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。 + +## 测试 + +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应和顺序帧。运行时测试覆盖乐观顺序、帧/响应优先级、拒绝回滚、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、最近更新置顶与手动拖拽、当前视图标记、展开区段的 Workspace 命中、列表外 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index b2c2248876..5a7aa0bae4 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -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 docs/subsystems/workspace.md -workspace.md: ca088a2091a7f47a3d52992fec13fae44061a608 -workspace.zh.md: e414c759a043f934e1a8b5d89c7a3b6101bbb6f4 +workspace.md: dba519f7eecd0f50ab91e1e2346f09ade154029d +workspace.zh.md: be91c4a5aeacc9ad379a784f93c6c8535eefb11e diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index ca088a2091..dba519f7ee 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -194,6 +194,15 @@ list(): Workspace[] */ delete(id: WorkspaceId): Promise +/** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ +insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise + /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. @@ -215,5 +224,5 @@ async resolveByPath(path: string): Promise Types: [SessionId](core.md) -Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index e414c759a0..be91c4a5ae 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -194,6 +194,15 @@ list(): Workspace[] */ delete(id: WorkspaceId): Promise +/** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ +insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise + /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. @@ -215,5 +224,5 @@ async resolveByPath(path: string): Promise Types: [SessionId](core.md) -Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3d3c29e757..b69029bc2b 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: 7c835deb58db149710495f97a2553c3de58d99da -README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09 +README.md: 5ac081d6f257bc1c3a7d8a2dd75234f72f209da7 +README.zh.md: 15a1a4602fbb4ae6bd56d728b17782da8cfa4693 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 7c835deb58..5ac081d6f2 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -13,7 +13,7 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen ## Workspace and Session lists -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspacesService.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request rolls back. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. `SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. @@ -31,7 +31,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## New Session and the blank mirror -`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. ## Pending queue projection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index edf4473bec..15a1a4602f 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -13,7 +13,7 @@ ## Workspace 与 Session 列表 -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会回滚。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 `SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 @@ -31,7 +31,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## New Session 与 blank 镜像 -`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 ## 待处理队列投影 diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 92507c838e..7365c8d654 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -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-sidebar/README.md -README.md: 45ae267d98b17bbc612cf932f5b95b42ba6ff4bf -README.zh.md: a9fb927305d0bab5fb4d27adbfdbec90dfa1dd6d +README.md: 10a3bdaf96512124e88b82f643930241671e066d +README.zh.md: 11b0aa142cf62626ab6105e2c405d506e35349b0 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 45ae267d98..10a3bdaf96 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar shell plugin: the wordmark, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar. +New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. -`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. +`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store. Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows. @@ -25,5 +25,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — no done/error notification sources are available. -- **Group-by supports Workspace only** — Update and Status are not available strategies. +- **Workspace browser behavior is composition-owned** — grouping, ordering, search, and row state belong to [ui-workspace](../ui-workspace/README.md), not this shell. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index a9fb927305..11b0aa142c 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的会话显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 +侧边栏外壳插件:负责字标、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 -New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端会话为目标。Workspace Intent 不会出现在侧边栏中。 +New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 -`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。 +`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。 栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。 @@ -25,5 +25,5 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 - **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:没有可用的 done/error 通知数据源。 -- **分组只支持 Workspace**:Update 和 Status 不是可用策略。 +- **Workspace 浏览行为由组合持有**:分组、排序、搜索与行状态都属于 [ui-workspace](../ui-workspace/README.md),不属于此外壳。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1a1dd057f0..bff5c6410a 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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: 1ec07bd41e72bb5a26b2cfc3bf90e57e7d92db08 -README.zh.md: 8edd0fed6d3bdefd9df339a8b3d0588533264538 +README.md: f54b8b0b2070c81089be1703b536492522d38774 +README.zh.md: b1977e7f6a67fa7bcb6751d7b214cfff4bfdbb4f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 1ec07bd41e..f54b8b0b20 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,9 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. 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 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 Session order: **Manual** follows the Host Workspace account, while **Last updated** keeps a browser-local editable order and moves a Session to the front whenever a newer `updatedAt` arrives. 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. 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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 8edd0fed6d..b1977e7f6a 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,9 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和 Session 顺序放在一起:**手动排序**遵循 Host Workspace 记账顺序,**最近更新**则维护可编辑的浏览器本地顺序,并在收到更大的 `updatedAt` 时把该 Session 移到首位。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 + +折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起空查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 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 和归档都从首条提示词落地后才可用。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 72568f241d..aa648fdc00 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: 5fe19af8069766c56f8926ccef88dc1d9fb3c950 -README.zh.md: bdb26a63832c1461b4e56798e64c1253a916118d +README.md: f7023407c4fad559847f71e56804fe0737e09966 +README.zh.md: 2502f550c8bf76137337d879b5013ba38e734bb6 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5fe19af806..f7023407c4 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -40,7 +40,7 @@ Pending queued input is a live control-plane contract, not conversation history. Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index bdb26a6383..2502f550c8 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -40,7 +40,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found`。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 1c310ab8e0..e0c4500cfb 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1384,6 +1384,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'delete(id: WorkspaceId): Promise', jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', }, + { + signature: 'insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise', + jsDoc: '/**\n * Move one workspace within the durable display order, DOM-insertBefore-like.\n * With an anchor it lands before that workspace; without one it appends.\n * @param id - Workspace to move.\n * @param beforeId - Workspace anchor; omitted appends.\n * @returns the complete committed workspace order.\n */', + }, { signature: 'archiveSession(sessionId: SessionId): Promise', jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\n */', diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 63d5ce0e8e..caeadcc3d8 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -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/workspace/workspace/README.md -README.md: 057765e38de9cc700210eb8edeb1ddc7ffc861ff -README.zh.md: 7416875dbf2ee1652f6e1fa1663144d7407a1ae7 +README.md: 4f7e2925ca7572dc3cc32c2a294bd1f40b243254 +README.zh.md: 2f4f38dea881b2c8a2bb135c8f7b1b3c88b9190a diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 057765e38d..4f7e2925ca 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -10,9 +10,10 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title. - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. +- `ctx.workspace.insertBefore(id, before?)` — moves a registered Workspace within durable registry order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A source or anchor absent from the registry rejects without writing; a self-anchor or move to the current position resolves without writing. The returned id list is the complete committed order. - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. -- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Workspace order never changes. +- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Registry Workspace order never changes. - `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 7416875dbf..2f4f38dea8 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -10,9 +10,10 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径可以共用显示标题。 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 +- `ctx.workspace.insertBefore(id, before?)`:在持久注册表顺序内移动一个已注册 Workspace,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。来源或锚点不在注册表中时拒绝且不写入;以自身为锚点或移动到当前位置时直接完成且不写入。返回的 id 列表是完整的已提交顺序。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 -- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。Workspace 顺序绝不改变。 +- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。注册表中的 Workspace 顺序绝不改变。 - `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 From d672eace2f42918a5ca2459704c757bb1ac7a6d8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:42:44 +0800 Subject: [PATCH 16/37] style(client): refine drag insertion markers --- .../src/client/SidebarRoot.module.css | 4 +++ .../ui-sidebar/tests/sidebar-styles.spec.ts | 4 +++ .../src/client/WorkspaceBrowser.module.css | 26 ++++++++++++++----- .../src/client/rows/Rows.module.css | 24 +++++++++++------ .../ui-workspace/tests/browser-styles.spec.ts | 18 +++++++++++++ 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 2cc99e3159..17333b5ccc 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -215,12 +215,16 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-sidebar-inline-padding)); + padding-left: 4px; overflow: hidden; } .collapsed .regionArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Foot seat: a pure layout socket pinned under the region; the ui-settings diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts index 63721258c9..c4abce1911 100644 --- a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts +++ b/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts @@ -30,9 +30,13 @@ describe('SidebarRoot.module.css inset', () => { const root = declarations('.root') expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px') expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)') + expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px') + expect(declarations('.regionArea')?.get('padding-left')).toBe('4px') expect(declarations('.regionArea')?.get('margin-right')).toBe( 'calc(-1 * var(--dsh-sidebar-inline-padding))', ) + expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBe('0') + expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0') expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') }) }) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 1431096afe..ee79d4b56a 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -296,12 +296,16 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-session-list-edge-inset)); + padding-left: 4px; overflow: hidden; } .rail .listArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Relative for the bottom fade overlay. */ @@ -342,7 +346,9 @@ flex: 1; min-height: 0; overflow-y: auto; + margin-left: -4px; margin-right: var(--dsh-session-list-scrollbar-offset); + padding-left: 4px; padding-right: calc( var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width) @@ -386,20 +392,28 @@ content: ''; position: absolute; z-index: 1; - left: 4px; + left: -4px; right: 4px; - height: 2px; - border-radius: 999px; - background: var(--dsw-alias-state-business-primary); + height: 12px; + background: + radial-gradient( + circle at 6px 6px, + transparent 0 3px, + var(--dsw-alias-state-business-primary) 3px 5px, + transparent 5px + ), + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 10px 5px / calc(100% - 10px) 2px no-repeat; pointer-events: none; } .workspaceDropBefore::before { - top: -3px; + top: -8px; } .workspaceDropAfter::after { - bottom: -3px; + bottom: -8px; } .sessionOverflowButton { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 548407fd40..528cc16f59 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -236,8 +236,8 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Session drag insert line: an independent 2px rule between rows, absolutely - positioned so it neither resembles a row border nor changes layout. */ +/* Session drag insert marker: a hollow leading dot and 2px rule between rows, + absolutely positioned so it neither resembles a row border nor changes layout. */ .sessionRow.dropBefore, .sessionRow.dropAfter { position: relative; @@ -248,20 +248,28 @@ content: ''; position: absolute; z-index: 1; - left: 4px; + left: 0; right: 4px; - height: 2px; - border-radius: 999px; - background: var(--dsw-alias-state-business-primary); + height: 12px; + background: + radial-gradient( + circle at 6px 6px, + transparent 0 3px, + var(--dsw-alias-state-business-primary) 3px 5px, + transparent 5px + ), + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 10px 5px / calc(100% - 10px) 2px no-repeat; pointer-events: none; } .sessionRow.dropBefore::before { - top: -2px; + top: -7px; } .sessionRow.dropAfter::after { - bottom: -2px; + bottom: -7px; } /* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ diff --git a/packages/client/ui-workspace/tests/browser-styles.spec.ts b/packages/client/ui-workspace/tests/browser-styles.spec.ts index 86abd521bc..9930aea0b1 100644 --- a/packages/client/ui-workspace/tests/browser-styles.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.spec.ts @@ -48,9 +48,13 @@ describe('WorkspaceBrowser.module.css list', () => { expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px') expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px') expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)') + expect(listArea?.get('margin-left')).toBe('-4px') + expect(listArea?.get('padding-left')).toBe('4px') expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))') expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)') expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)') + expect(list?.get('margin-left')).toBe('-4px') + expect(list?.get('padding-left')).toBe('4px') expect(list?.get('padding-right')).toBe([ 'calc(', 'var(--dsh-session-list-edge-inset)', @@ -72,6 +76,20 @@ describe('WorkspaceBrowser.module.css list', () => { expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px') }) + it('draws drag targets as a hollow leading dot joined to the insertion line', () => { + const workspaceMarker = declarations('.workspaceDropBefore::before') + const sessionMarker = rowDeclarations('.sessionRow.dropBefore::before') + expect(workspaceMarker?.get('left')).toBe('-4px') + expect(sessionMarker?.get('left')).toBe('0') + for (const marker of [workspaceMarker, sessionMarker]) { + expect(marker?.get('height')).toBe('12px') + expect(marker?.get('background')).toContain('radial-gradient') + expect(marker?.get('background')).toContain('linear-gradient') + expect(marker?.get('background')).toContain('var(--dsw-alias-state-business-primary) 3px 5px') + expect(marker?.get('background')).toContain('10px 5px / calc(100% - 10px) 2px') + } + }) + 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') From 31768a348ee037160a609d87031979a5a248be69 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:57:34 +0800 Subject: [PATCH 17/37] fix(client): update recency from user messages --- .../runtime/src/client/sessions/manager.ts | 16 +++++++ packages/client/runtime/tests/manager.spec.ts | 42 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index a6e7bf867c..c114be3c66 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -71,6 +71,7 @@ type SessionListMutation = | { kind: 'upsert'; summary: SessionSummary } | { kind: 'remove'; sessionId: SessionId } | { kind: 'status'; sessionId: SessionId; running: boolean } + | { kind: 'activity'; sessionId: SessionId; updatedAt: number } /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } @@ -685,6 +686,16 @@ export class SessionManager { handleMuxEnvelope(envelope: RpcRequest): void { const frame = envelope.payload if (frame.type === 'stream/error') return // Controller already treats this as stream failure + if ( + frame.type === 'session/event' + && frame.event.type === 'user/message' + && frame.event.data.source.kind === 'user' + ) { + // session.list supplies the cold baseline, while a direct prompt or an + // admitted steer advances it between pulls. Max keeps replayed or + // repaired older user messages from moving the row backwards. + this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time }) + } if (frame.type === 'session/projection') { // Finished host-computed value: land it in the resident store whether or // not the Session is instantiated (list rows read the 'title' key). The @@ -1115,6 +1126,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi && (summary.running !== mutation.running || (mutation.running && summary.blank)) ? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running } : summary) + case 'activity': + return summaries.map(summary => summary.sessionId === mutation.sessionId + && mutation.updatedAt > summary.updatedAt + ? { ...summary, updatedAt: mutation.updatedAt } + : summary) case 'engaged': return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank ? { ...summary, blank: false } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 7eba55fd64..cd6e6b9a3a 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' -import { entries, plainTurn } from './event-script.ts' +import { entries, ev, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId @@ -113,6 +113,46 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) + it('advances list activity only for direct user messages', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) + const manager = new SessionManager(api) + await manager.refreshList() + + // Both a new prompt and an admitted steer land as a user-sourced message. + const activity = { ...ev.user(10, 'new'), time: 500 } + manager.handleMuxEnvelope({ + rpcId: 'activity' as never, + payload: { type: 'session/event', sessionId: S1, event: activity }, + }) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + + manager.handleMuxEnvelope({ + rpcId: 'older' as never, + payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } }, + }) + manager.handleMuxEnvelope({ + rpcId: 'assistant' as never, + payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } }, + }) + + const injected = ev.user(12, 'context') + if (injected.type !== 'user/message') throw new Error('user builder returned another event type') + manager.handleMuxEnvelope({ + rpcId: 'injected' as never, + payload: { + type: 'session/event', + sessionId: S1, + event: { + ...injected, + time: 700, + data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } }, + }, + }, + }) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + }) + it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) From 2ad471123d99acb337ceec6a47ed91342e75ea0f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:57:43 +0800 Subject: [PATCH 18/37] fix(client): persist workspace drag order --- .../runtime/src/client/workspaces/manager.ts | 17 ++++-- .../runtime/tests/workspaces-service.spec.ts | 6 +++ .../src/client/WorkspaceBrowser.tsx | 54 +++++++++++++------ .../tests/workspace-browser.spec.tsx | 28 ++++++++++ 4 files changed, 84 insertions(+), 21 deletions(-) diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 89f96295ef..6f2e95d351 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -173,10 +173,19 @@ export class WorkspaceManager { const frameGeneration = this.orderFrameGeneration const previousOrder = this.itemViews().map(workspace => workspace.workspaceId) this.installOrder(insertIdBefore(previousOrder, workspaceId, beforeWorkspaceId)) - const { result } = await this.api.workspace.insertBefore({ - workspaceId, - ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, - }) + let result: RpcResult<{ workspaceIds: WorkspaceId[] }> + try { + ;({ result } = await this.api.workspace.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + })) + } catch (error) { + if (requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(previousOrder) + } + throw error + } if (result.ok && requestGeneration === this.orderRequestGeneration && frameGeneration === this.orderFrameGeneration) { this.installOrder(result.value.workspaceIds) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 2c4ed551d8..db80e98337 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -107,6 +107,12 @@ describe('WorkspaceManager', () => { 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']) + + api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down')) + const disconnected = manager.insertBefore(wid('three'), wid('one')) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + await expect(disconnected).rejects.toThrow('transport down') + 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 () => { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 637b89c162..eeb47e2cb1 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -200,9 +200,10 @@ function SessionTree({ const [drag, setDrag] = useState(null) const sessionDropCommitted = useRef(false) const [workspaceDrag, setWorkspaceDrag] = useState(null) - const sessionDragging = drag !== null + const workspaceDropCommitted = useRef(false) + const nativeDragActive = drag !== null || workspaceDrag !== null useEffect(() => { - if (!sessionDragging) return + if (!nativeDragActive) return // Row hover still owns the insertion marker. Accept the native drag at // document level so releasing outside the list is not rendered as a // rejected drop before dragend commits that last marker. @@ -217,7 +218,7 @@ function SessionTree({ document.removeEventListener('dragover', acceptDrag) document.removeEventListener('drop', acceptDrop) } - }, [sessionDragging]) + }, [nativeDragActive]) const currentGroup = current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) @@ -310,6 +311,26 @@ function SessionTree({ console.warn('session reorder rejected:', reason) }) } + const commitWorkspaceDrag = ( + activeDrag: WorkspaceDragState, + over: NonNullable, + ): void => { + if (workspaceDropCommitted.current) return + workspaceDropCommitted.current = true + setWorkspaceDrag(null) + const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === over.id) + if (rowIndex === -1) return + const anchor = over.half === 'before' ? over.id : workspaces[rowIndex + 1]?.workspaceId + if (anchor === activeDrag.workspaceId) return + const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId) + const anchorIndex = anchor === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + insertWorkspaceBefore(activeDrag.workspaceId, anchor).catch((reason: unknown) => { + console.warn('workspace reorder rejected:', reason) + }) + } return (
@@ -323,8 +344,18 @@ function SessionTree({ ? workspaceDrag.over.half : null const workspaceDragProps = workspaceId === undefined ? undefined : { - start: () => { setWorkspaceDrag({ workspaceId, over: null }) }, - end: () => { setWorkspaceDrag(null) }, + start: () => { + workspaceDropCommitted.current = false + setWorkspaceDrag({ workspaceId, over: null }) + }, + end: () => { + if (workspaceDrag?.over !== null && workspaceDrag?.over !== undefined) { + commitWorkspaceDrag(workspaceDrag, workspaceDrag.over) + } else { + setWorkspaceDrag(null) + } + workspaceDropCommitted.current = false + }, } const hoverWorkspace = workspaceId === undefined ? undefined @@ -337,18 +368,7 @@ function SessionTree({ ? undefined : (half: 'before' | 'after') => { if (workspaceDrag === null) return - const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) - const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId - setWorkspaceDrag(null) - if (anchor === workspaceDrag.workspaceId) return - const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceDrag.workspaceId) - const anchorIndex = anchor === undefined - ? workspaces.length - : workspaces.findIndex(workspace => workspace.workspaceId === anchor) - if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return - insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => { - console.warn('workspace reorder rejected:', reason) - }) + commitWorkspaceDrag(workspaceDrag, { id: workspaceId, half }) } return ( // Group section: header row + expanded top-level session rows. The diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index ca48733dda..414353f684 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -629,6 +629,34 @@ describe('WorkspaceBrowser', () => { expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) }) + it('accepts a document-level drop and commits the last Workspace marker on drag end', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + mount({ + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', []), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let target = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (target.parentElement?.getAttribute('role') !== 'tree') { + target = target.parentElement as HTMLElement + } + target.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'dragOver', 105) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(source) + 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)]) From 34e90dc3fe370630b50a421696b1323944138cc5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 16:03:13 +0800 Subject: [PATCH 19/37] test(client): accept session creation timestamps --- packages/client/connection/tests/fixture.spec.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index b7d4d12e8d..5d64fa4682 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -264,7 +264,9 @@ describe('createFixtureApi', () => { await consuming if (!created.result.ok) throw new Error('create failed') const createdId = created.result.value.sessionId - expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }]) + expect(seen).toEqual([{ + type: 'host/session-added', sessionId: createdId, createdAt: expect.any(Number), blank: true, cwd: '/tmp/fixture', + }]) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true) @@ -699,7 +701,9 @@ describe('createFixtureApi', () => { await consuming // The session lands with the workspace's path as cwd, and the account // write pushes the fresh workspace snapshot after session-added. - expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' }) + expect(seen[0]).toEqual({ + type: 'host/session-added', sessionId: id, createdAt: expect.any(Number), blank: true, cwd: '/tmp/fixture', + }) expect(seen[1]).toMatchObject({ type: 'host/workspace-changed', workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] }, @@ -728,7 +732,10 @@ describe('createFixtureApi', () => { expect(frames[0]).toMatchObject({ type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] }, }) - expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path }) + expect(frames[1]).toEqual({ + type: 'host/session-added', sessionId: preallocated, createdAt: expect.any(Number), blank: true, + cwd: made.result.value.workspace.path, + }) const retried = await api.sessions.create(req({ workspaceId: made.result.value.workspace.workspaceId, From 5b1da441d5e767992d8ee6ecc96fb5236f5c21c9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 16:46:52 +0800 Subject: [PATCH 20/37] fix(client): refine workspace view controls --- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 29 ++++---- .../client/ui-workspace/src/client/locales.ts | 6 +- .../client/ui-workspace/src/client/stores.ts | 6 +- .../tests/workspace-browser.spec.tsx | 70 ++++++++++++++----- 7 files changed, 77 insertions(+), 42 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index bff5c6410a..81b52ef012 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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: f54b8b0b2070c81089be1703b536492522d38774 -README.zh.md: b1977e7f6a67fa7bcb6751d7b214cfff4bfdbb4f +README.md: 24d73beba56f527eb57276accb2693255bad9020 +README.zh.md: 85a5ce9210dd084e6e6e0746eb19aac12d98d54d diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f54b8b0b20..24d73beba5 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -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 Session order: **Manual** follows the Host Workspace account, while **Last updated** keeps a browser-local editable order and moves a Session to the front whenever a newer `updatedAt` arrives. Workspace drag order is Host-durable in either Session order mode. +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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index b1977e7f6a..85a5ce9210 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和 Session 顺序放在一起:**手动排序**遵循 Host Workspace 记账顺序,**最近更新**则维护可编辑的浏览器本地顺序,并在收到更大的 `updatedAt` 时把该 Session 移到首位。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 +该浏览器通过全局运行时钩子将 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 时既不会清除查询,也不会跳转至特定事件。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index eeb47e2cb1..305cabad08 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -118,11 +118,11 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { // be cut off at the header's bounds. portal anchor={( - +