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

@@ -19,6 +19,8 @@ export interface RpcMethodMap {
'host.describe': HostApi['describe']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */

View File

@@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap {
'workspace-not-found': { workspaceId: string }
'workspace-invalid-path': { path: string }
'workspace-name-conflict': { name: string }
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
'agent-busy': { reason: string }
'internal': {}
}

View File

@@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({
workspace: workspaceViewSchema,
created: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
/** workspace.rename request payload: the new title must be non-blank. */
export const workspaceRenameRequestSchema = z.object({
workspaceId: workspaceIdSchema,
title: z.string(),
}).refine(
payload => payload.title.trim() !== '',
{ message: 'workspace.rename requires a non-blank title' },
) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
/** workspace.rename response value. */
export const workspaceRenameValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
export const workspaceInsertSessionBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema,
sessionId: sessionIdSchema,
beforeSessionId: sessionIdSchema.optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
/** workspace.insertSessionBefore response value. */
export const workspaceInsertSessionBeforeValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>

View File

@@ -24,7 +24,10 @@ export interface WorkspaceView {
path: string
/** Unique display title (defaults to the path basename at create). */
title: string
/** Sessions accounted under this workspace, newest-first for display. */
/**
* Sessions accounted under this workspace, in manually owned order
* (attach prepends, insertSessionBefore reorders; activity never does).
*/
sessionIds: SessionId[]
/** ISO-8601 creation instant. */
createdAt: string
@@ -52,4 +55,27 @@ export interface WorkspaceApi {
*/
create(request: RpcRequest<{ path?: string; name?: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
/**
* Renames a workspace. `title` is trimmed and must be non-empty
* (schema-enforced). An unknown id fails with `workspace-not-found`; a
* title equal to another workspace's fails with `workspace-name-conflict`.
* Renaming to the current title is a no-op success (no durable write).
*/
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView }>>
/**
* Moves an accounted session within its workspace's manual order,
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
* before that anchor; omitted appends to the end. An unknown workspace
* fails with `workspace-not-found`; a session or anchor not accounted by
* the workspace fails with `workspace-move-invalid`. A move to the current
* position is a no-op success.
*/
insertSessionBefore(request: RpcRequest<{
workspaceId: WorkspaceId
sessionId: SessionId
beforeSessionId?: SessionId
}>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
}