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

@@ -15,6 +15,17 @@ import type { WorkspaceRecord } from './spec.ts'
import type { Workspace, WorkspaceId } from './types.ts'
import { realpathNormalize } from './paths.ts'
/** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */
export class WorkspaceMoveInvalidError extends Error {
/**
* @param message - Which id was unaccounted and where.
*/
constructor(message: string) {
super(message)
this.name = 'WorkspaceMoveInvalidError'
}
}
/**
* The registry-owned machinery an entity mutates through. Entities never see
* the registry itself — only the open table, the canonical session-path
@@ -137,30 +148,27 @@ export class WorkspaceEntity implements Workspace {
: { ...record, sessionIds: [sessionId, ...record.sessionIds] })
}
/**
* Test the durable candidate account without applying header projection.
* @param sessionId - Candidate session id.
* @returns whether this workspace's stored account contains the id.
*/
hasSession(sessionId: SessionId): boolean {
return this.record.sessionIds.includes(sessionId)
}
/**
* Move one validated accounted session to the front without touching peers.
* @param sessionId - Accounted session whose activity was observed.
*/
async touchSession(sessionId: SessionId): Promise<void> {
if (
this.host.sessionPath(sessionId) !== this.record.path
|| this.record.sessionIds[0] === sessionId
) return
await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId
? record
: {
...record,
sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)],
})
async insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void> {
await this.mutate((record) => {
if (!record.sessionIds.includes(sessionId)) {
throw new WorkspaceMoveInvalidError(
`cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`,
)
}
if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) {
throw new WorkspaceMoveInvalidError(
`cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': `
+ 'the anchor session is not accounted',
)
}
if (beforeSessionId === sessionId) return record
const without = record.sessionIds.filter(id => id !== sessionId)
const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
return sessionIds.every((id, index) => id === record.sessionIds[index])
? record
: { ...record, sessionIds }
})
}
async detachSession(sessionId: SessionId): Promise<void> {

View File

@@ -14,6 +14,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain'
import { WorkspaceEntity } from './entity.ts'
import type { WorkspaceEntityHost } from './entity.ts'
export { WorkspaceMoveInvalidError } from './entity.ts'
import { realpathNormalize } from './paths.ts'
import { workspaceDomainSpec } from './spec.ts'
import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
@@ -47,6 +49,7 @@ export class WorkspaceNameConflictError extends Error {
}
}
declare module 'cordis' {
interface Context {
workspace: WorkspaceRegistry
@@ -82,7 +85,6 @@ export class WorkspaceRegistry extends Service {
private readonly headers = new Map<SessionId, SessionHeader>()
private readonly sessionPaths = new Map<SessionId, string>()
private readonly invalidSessionPaths = new Map<SessionId, string>()
private readonly pendingTouches = new Map<SessionId, Promise<void>>()
private operationTail: Promise<void> = Promise.resolve()
private readonly host: WorkspaceEntityHost = {
@@ -120,13 +122,6 @@ export class WorkspaceRegistry extends Service {
this.validateStoredState(this.requireState())
this.rebuildEntities()
this.reportFilteredCandidates()
// Session activity is authoritative even when no RPC/SSE consumer is
// connected. This service-owned listener is disposed with the registry.
this.ctx.on('session/event', (session) => {
void this.touchSession(session.id).catch((error: unknown) => {
this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`)
})
})
}
/**
@@ -173,32 +168,6 @@ export class WorkspaceRegistry extends Service {
})
}
/**
* Move one accounted, cwd-validated session to the front of its workspace.
* Ungrouped sessions and candidates filtered by the header check are
* no-ops. The owning workspace's relative position never changes.
* @param sessionId - Session whose activity was observed.
* @returns resolution after the possible record write.
*/
async touchSession(sessionId: SessionId): Promise<void> {
const pending = this.pendingTouches.get(sessionId)
if (pending !== undefined) {
await pending
return
}
for (const entity of this.entities.values()) {
if (!entity.hasSession(sessionId)) continue
const touch = entity.touchSession(sessionId)
this.pendingTouches.set(sessionId, touch)
try {
await touch
} finally {
this.pendingTouches.delete(sessionId)
}
return
}
}
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned

View File

@@ -41,10 +41,12 @@ export interface Workspace {
readonly updatedAt: string
/**
* Header-validated sessions in newest-first display order. The durable
* candidate account is filtered synchronously: missing headers, invalid
* cwd values, and canonical cwd mismatches are never returned. A subsequent
* workspace mutation prunes those filtered candidates durably.
* Header-validated sessions in manually owned order: a new session is
* prepended at attach, explicit reordering goes through
* `insertSessionBefore`, and activity never reorders. The durable candidate
* account is filtered synchronously: missing headers, invalid cwd values,
* and canonical cwd mismatches are never returned. A subsequent workspace
* mutation prunes those filtered candidates durably.
*/
readonly sessionIds: readonly SessionId[]
@@ -57,8 +59,7 @@ export interface Workspace {
/**
* Prepend a session to this workspace's candidate account. An already
* accounted id resolves without writing; activity-driven reordering uses
* `WorkspaceRegistry.touchSession` instead. A new id's live or persisted
* accounted id resolves without writing. A new id's live or persisted
* header cwd must resolve to an existing directory equal to {@link path};
* unknown ids, missing or invalid cwd values, and mismatches reject without
* writing.
@@ -67,6 +68,18 @@ export interface Workspace {
*/
attachSession(sessionId: SessionId): Promise<void>
/**
* Move an accounted session within the manual order, DOM-insertBefore-like:
* with an anchor the session lands before it, without one it appends to the
* end. Only the moved id changes position. A session or anchor absent from
* the account rejects without writing; a move to the current position
* resolves without writing (decided on the domain write chain).
* @param sessionId - The accounted session to move.
* @param beforeSessionId - Accounted anchor to insert before; omitted appends.
* @returns resolution after durability.
*/
insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>
/**
* Remove a session from this workspace's account. Idempotent: an id not on
* the account resolves without writing (decided on the domain write chain,