feat(web): session list one-list, hover card, row menus, rename, manual ordering

Sidebar session list grows the figma 239-10458 feature set and the
workspace/session browsing region moves wholesale into ui-workspace:

- Group-by menu (WorkSpace / In one list): flat mode lists every session
  top-level, strictly newest-first; the choice persists across reloads.
- Session rows get a 500ms hover detail card (title / relative time /
  status line) and a ... menu (Rename / Fork session / Delete session,
  visual-only for now); workspace headers get ... with Rename (wired) and
  Delete workspace (visual-only).
- workspace.rename RPC: trims, rejects duplicate titles on the create
  chain (workspace-name-conflict), no-op on same title; modal dialog with
  client-side duplicate pre-check.
- workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted
  anchor appends): HTML5 drag reorder of root sessions inside a workspace
  group; order truth stays host-side, the view refreshes from the
  response/changed frame.
- Activity pinning removed: the session/event touchSession chain is gone;
  workspace accounts are manually owned (new sessions prepend, explicit
  reordering only). Contracts and tests updated, api catalog regenerated.
- ui-sidebar reduced to the column shell (brand, fold state machine, New
  Session, Settings) exposing one sidebar.workspaces hole with a two-fact
  owner share {wide, expandSidebar}; ui-workspace owns the whole region
  (header, search, grouped/flat lists, dialogs, drag) plus the picker via
  a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and
  its deferral indirection are gone.
- ui-primitives: Menu gains label entries, danger rows, and
  closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled
  guard). Hover card and row menu never coexist.
This commit is contained in:
imccyu
2026-07-26 00:02:46 +08:00
parent 84be7cc622
commit ea8b1178cd
48 changed files with 1948 additions and 1133 deletions

View File

@@ -14,7 +14,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError,
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
WorkspaceMoveInvalidError, WorkspaceNameConflictError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
@@ -680,6 +681,72 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async rename(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `workspace "${payload.workspaceId}" not found`,
details: { workspaceId: payload.workspaceId },
})
}
const title = payload.title.trim()
// Uniqueness AND the same-title no-op both ride the create chain so
// they observe the state left by earlier queued renames — checked
// up front, a queued A→A could report success while an earlier A→B
// still lands afterwards.
const operation = workspaceCreationChain.then(async () => {
if (title === workspace.title) return
if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
throw new WorkspaceNameConflictError(title)
}
await workspace.setTitle(title)
})
workspaceCreationChain = operation.then(() => undefined, () => undefined)
try {
await operation
} catch (error: unknown) {
if (error instanceof WorkspaceNameConflictError) {
return err(request, {
code: 'workspace-name-conflict',
message: error.message,
details: { name: error.workspaceName },
})
}
throw error
}
return ok(request, { workspace: workspaceView(workspace) })
},
async insertSessionBefore(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `workspace "${payload.workspaceId}" not found`,
details: { workspaceId: payload.workspaceId },
})
}
try {
await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
} catch (error: unknown) {
// Only the entity's unaccounted-id rejection is the business code;
// storage/durability failures propagate as internal errors.
if (!(error instanceof WorkspaceMoveInvalidError)) throw error
return err(request, {
code: 'workspace-move-invalid',
message: error.message,
details: {
workspaceId: payload.workspaceId,
sessionId: payload.sessionId,
...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
},
})
}
return ok(request, { workspace: workspaceView(workspace) })
},
},
host: {