diff --git a/packages/client/runtime/src/client/sessions/service.ts.orig b/packages/client/runtime/src/client/sessions/service.ts.orig deleted file mode 100644 index deb1616a8a..0000000000 --- a/packages/client/runtime/src/client/sessions/service.ts.orig +++ /dev/null @@ -1,590 +0,0 @@ -/** - * SessionsService: root sessions service — list snapshot store (manager - * projection; carries `current`, the persisted selection every - * session-scoped surface keys off — migrated here from ui-layout per the - * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin - * Fiber + ctx.extend scope tag; one scope per session, agent id === session - * id), stable SessionBinding cache, ancestry walk. - * - * Scope lifecycle is stage-driven: a scope is minted lazily on first - * resolution (pure — resolution has no side effects and is render-safe); - * the event window and deferred teardown key off the STAGED session, which - * follows `list.current` exactly. Staging is the open signal: the window - * opens ⟺ the session is on stage (today the stage is `current`; the staged - * state can widen to a multi-pane list later). A session leaving the list - * tears its scope down immediately unless it is the staged one, whose scope - * survives frozen (read-only view) until the stage moves on. - */ -import type { Context, Fiber } from 'cordis' -import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' -import type { - HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, -} from '@deepseek-ai/dsh-client-ui-slots' -import type { SnapshotStore } from '../contract/store.ts' -import { createSnapshotStore } from '../contract/store.ts' -import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' -import { SessionManager } from './manager.ts' -import type { SessionListPhase } from './manager.ts' -import type { Session } from './session.ts' - -/** Session list row projected from the host list RPC plus live stream increments. */ -export interface SessionSummary { - id: SessionId - /** Latest durable log-backed title, absent until the host projects one. */ - title?: string - /** Human-facing label: durable title, project basename, then session id. */ - displayTitle: string - cwd?: string - parentId?: SessionId - running: boolean - /** - * Empty-log bit (host summary derivation mirror). List surfaces hide blank - * sessions; New Session reuses a blank one targeting the same workspace. - * Filtering stays with the consumer — the store carries every row. - */ - blank: boolean - updatedAt: number -} - -/** - * Session list store shape. `current` rides the same snapshot (arbitrated: - * the single useSessions standard hook reads list and selection together — - * sidebar highlighting and SessionProvider share one fact source). - */ -export interface SessionListState { - ids: SessionId[] - byId: Record - current: SessionId | undefined - /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ - phase: SessionListPhase -} - -/** Structured session-create failure. */ -export class SessionCreateError extends Error { - override readonly name = 'SessionCreateError' - - /** - * @param rpcError - Host business or folded transport error. - * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. - */ - constructor( - readonly rpcError: RpcError, - readonly requestedSessionId: SessionId | undefined, - ) { - super(`session create failed: ${rpcError.code}: ${rpcError.message}`) - } -} - -/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ -export interface SessionBinding { - readonly sessionId: SessionId - readonly session: Session - readonly ctx: Context -} - -// Scope primitives live in ../agents/scope.ts (the client mirror of host -// dsh-scope, keyed by Agent identity); re-exported here so existing -// consumers keep their import site. -export { scopeOf } from '../agents/scope.ts' - -/** - * Workspace display title of a session cwd: the path's last non-empty - * segment (both separators accepted; trailing separators ignored), or '' - * for separator-only paths — callers own their fallback (session id, raw - * cwd, default-directory copy). The repo-wide single basename derivation — - * every surface naming a workspace (picker rows, toggle labels, list titles) - * calls this instead of re-splitting paths. - * @param cwd - workspace directory path. - * @returns basename title, or '' when no non-empty segment exists. - */ -export function workspaceTitleOf(cwd: string): string { - return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '' -} - -/** - * Display title projection: durable title, project directory basename, then - * the raw id. - */ -function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { - if (title !== undefined) return title - if (cwd !== undefined && cwd !== '') { - const base = workspaceTitleOf(cwd) - if (base !== '') return base - } - return id -} - -interface ScopeRecord { - fiber: Fiber - ctx: Context - binding: SessionBinding - /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */ - provideInfo: SessionProvideInfo -} - -/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */ -export interface SessionProvideContribution { - /** Bare observable sources, keyed by hook base name ('input' → useInput). */ - hooks?: Record> - /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */ - props?: Record -} - -/** - * Static declaration plus per-session resolver for one standard-kit - * contribution. The declared names let the renderer construct the same hook - * and prop surface while no session is current. - */ -export interface SessionProvideDescriptor { - /** Hook base names (`input` becomes `useInput`). */ - hooks?: readonly string[] - /** Plain standard-prop names. */ - props?: readonly string[] - /** Resolve every declared member for one definite session. */ - resolve(binding: SessionBinding): SessionProvideContribution -} - -/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ -export class SessionsService { - /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ - readonly list: SnapshotStore - /** The object-layer instance cluster and frame dispatch entry. */ - private readonly manager: SessionManager - - /** - * Persisted selection cell (the durable half of `list.current`). Private on - * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open} / {@link SessionsService.clear}. Projection - * validates it against the live list instead of destructively pruning, so a - * selection survives transient list states (reconnect re-pull) and - * resurfaces when its session returns. - */ - private readonly selection: SnapshotStore<{ sessionId?: SessionId }> - - private readonly scopes = new Map() - /** Registered per-session standard-props providers, in registration order. */ - private readonly providers: SessionProvideDescriptor[] = [] - /** Static no-session projection, rebuilt only when the provider roster changes. */ - private maybeInfo: SessionMaybeProvideInfo - /** - * The staged session id — follows `list.current` exactly, holding its last - * defined value across masked gaps (a transiently absent selection blanks - * `current` without moving the stage, so reconnect re-pulls and removals - * keep the staged scope's frozen view alive until the stage moves on). - */ - private watched: SessionId | undefined - /** Removed-while-staged sessions whose teardown waits for the stage to move away. */ - private readonly deferredRemovals = new Set() - - /** - * @param ctx - client root context (scope fibers mount under it). - * @param api - wire client shared with every Session. - */ - constructor(private readonly rootCtx: Context, api: IApiClient) { - this.selection = createSnapshotStore<{ sessionId?: SessionId }>( - {}, - { persist: { name: 'dsh.sessions.current' } }) - this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId) - this.list = createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'pending', - }) - // The manager owns wire truth; the store is its projection. Manager - // notifications are already microtask-batched. - this.manager.subscribe(() => { this.projectList() }) - // Stage follower: every current write (open() and projection alike) - // re-evaluates staging, so startup restore (persisted selection validated - // by the projection) and reconnect resurfacing open their window with no - // dedicated code path. Safe to run synchronously inside the store notify: - // the follower writes no list state — session.open()'s synchronous prefix - // touches only session-side state and its own microtask-batched notifier. - this.list.subscribe(() => { this.followCurrent() }) - // The runtime's own contribution comes first: useSession rides the same - // provide channel every plugin uses (no renderer special case). - this.providers.push({ - hooks: ['session'], - resolve: binding => ({ hooks: { session: binding.session } }), - }) - this.maybeInfo = this.materializeMaybeProvideInfo() - rootCtx.reflect.provide('sessions', this, undefined) - } - - /** - * Register a per-session standard-props provider: every session-scope slot - * component receives the contributed members as standard props (`hooks` - * sources become `use` selector hooks on the render side; `props` - * spread verbatim). Contributions materialize lazily with the session's - * scope record and die with it. Registration order is resolution order; - * duplicate member names fail loud at materialization. - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops). - */ - provide(descriptor: SessionProvideDescriptor): () => void { - this.providers.push(descriptor) - // Scopes may already exist (boot order: the list lands and resolves - // scopes before later plugins register) — their bundles must include - // every provider by first render, so re-materialize on roster change. - this.rematerializeProvideBundles() - return () => { - const at = this.providers.indexOf(descriptor) - if (at >= 0) this.providers.splice(at, 1) - this.rematerializeProvideBundles() - } - } - - /** Rebuild every live scope's standard-props bundle after a provider roster change. */ - private rematerializeProvideBundles(): void { - this.maybeInfo = this.materializeMaybeProvideInfo() - for (const record of this.scopes.values()) { - record.provideInfo = this.materializeProvideInfo(record.binding) - } - } - - /** Build the static no-session kit and reject duplicate declared names. */ - private materializeMaybeProvideInfo(): SessionMaybeProvideInfo { - const hooks: Record = {} - const props: Record = {} - for (const descriptor of this.providers) { - for (const name of descriptor.hooks ?? []) { - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = undefined - } - for (const name of descriptor.props ?? []) { - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = undefined - } - } - return { sessionId: undefined, hooks, props } - } - - /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */ - private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo { - const hooks: Record> = {} - const props: Record = {} - for (const descriptor of this.providers) { - const contribution = descriptor.resolve(binding) - const contributedHooks = contribution.hooks ?? {} - const contributedProps = contribution.props ?? {} - for (const name of Object.keys(contributedHooks)) { - if (!(descriptor.hooks ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared hook "${name}"`) - } - } - for (const name of Object.keys(contributedProps)) { - if (!(descriptor.props ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared prop "${name}"`) - } - } - for (const name of descriptor.hooks ?? []) { - const source = contributedHooks[name] - if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = source - } - for (const name of descriptor.props ?? []) { - if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = contributedProps[name] - } - } - return { sessionId: binding.sessionId, hooks, props } - } - - /** - * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere. - * @param id - session id (must exist in the list store). - */ - open(id: SessionId): void { - this.manager.select(id) - } - - /** - * Clear the current selection so the layout shows the no-session empty - * state (new-session affordance and the workspace preselection flow). - * Wipes the persisted selection too — a reload stays on empty until the - * user opens or starts a session. The staged scope keeps its frozen view - * per the masked-gap contract until the next open() moves the stage. - */ - clear(): void { - this.manager.clearSelection() - } - - /** - * Refresh the real Session baseline, reusing an in-flight pull. - * @returns completion of the current or newly started baseline pull. - */ - refresh(): Promise { - return this.manager.refreshList() - } - - /** - * Route a mux stream envelope into the Session object layer. - * @param envelope - validated mux stream envelope. - */ - handleMuxEnvelope(envelope: Parameters[0]): void { - this.manager.handleMuxEnvelope(envelope) - } - - /** - * Route a Host stream envelope into the Session object layer. - * @param envelope - validated Host stream envelope. - */ - handleHostEnvelope(envelope: Parameters[0]): void { - this.manager.handleHostEnvelope(envelope) - } - - /** Rebuild the Session baseline and every opened window after connection. */ - handleConnected(): void { - this.manager.handleConnected() - } - - /** - * Create a session on the host. Resolution guarantee: by the time the - * promise resolves, the created session is in the list store and - * {@link SessionsService.binding} resolves it — callers (New Session - * draft hand-off) may address the scope synchronously, without waiting a - * notifier flush. The synchronous projection below makes this structural - * rather than an accident of microtask ordering. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. - * @throws {SessionCreateError} with the requested id. - */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { - const result = await this.manager.create(opts) - if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) - this.projectList() - return result.value.sessionId - } - - /** - * Resolve an Agent-scoped context view (use-and-discard). - * @param id - session id (the agent identity — 1:1 same axis). - * @returns scoped ctx, or undefined for a session neither listed nor already scoped. - */ - scope(id: SessionId): Context | undefined { - return this.resolve(id)?.ctx - } - - /** - * Read the Agent scope tag off a context. Service-method seam: fetch - * bundles must reach scope resolution through ctx.sessions — a cross-bundle - * value import of the standalone helper would inline a second module - * instance whose private tag Symbol never matches. - * @param ctx - any client context. - * @returns the session id, or undefined on root contexts. - */ - scopeOf(ctx: Context): SessionId | undefined { - return scopeTagOf(ctx) - } - - /** - * Resolve the business Session behind an Agent-scoped context — the one - * hop every scoped consumer (event listeners, per-session controllers) - * takes from ctx-space into object-space (the client mirror of host - * `agent.session`). Same service-method seam as - * {@link SessionsService.scopeOf}. - * @param ctx - an Agent-scoped context. - * @returns the Session, or undefined when the ctx is untagged or its scope was pruned. - */ - sessionOf(ctx: Context): Session | undefined { - const id = scopeTagOf(ctx) - if (id === undefined) return undefined - return this.scopes.get(id)?.binding.session - } - - /** - * Resolve the stable session binding (scope-addressed assembly feed). Pure - * resolution — no staging, no window side effects. - * @param id - session id. - * @returns binding, or undefined for a session neither listed nor already scoped. - */ - binding(id: SessionId): SessionBinding | undefined { - return this.resolve(id)?.binding - } - - /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. - */ - provideInfo(id: string): SessionProvideInfo | undefined { - return this.resolve(id as SessionId)?.provideInfo - } - - /** - * Resolve the current-session-optional standard kit. Unknown or absent ids - * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. - */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo - } - - /** - * Move the stage to the list's current session: sweep teardowns deferred - * behind the previous occupant and pull the new occupant's history window. - * Staging IS the open signal — the window opens ⟺ the session is on stage - * — and open() is idempotent (an in-flight or completed open no-ops; a - * failed one retries the next time current is touched). - */ - private followCurrent(): void { - const snapshot = this.list.getSnapshot() - const current = snapshot.current - // A masked gap (current blanked while the selection's session is - // transiently absent) holds the stage: tearing down on the gap would - // destroy exactly the frozen scope the mask exists to preserve. - if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return - this.watched = current - this.sweepDeferred() - const record = this.resolve(current) - /* v8 ignore next 3 -- defensive: current is always a listed id (open() - * validates and the projection masks absent selections), so resolve - * cannot miss; kept so a future current writer cannot crash the notify. */ - if (record !== undefined) { - void record.binding.session.open() - } - } - - /** - * Breadcrumb feed: walk parentId links inside the list store. - * @param id - session id. - * @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk). - */ - ancestry(id: SessionId): SessionSummary[] { - const { byId } = this.list.getSnapshot() - const chain: SessionSummary[] = [] - let cursor: SessionId | undefined = id - while (cursor !== undefined) { - const summary: SessionSummary | undefined = byId[cursor] - if (summary === undefined || chain.includes(summary)) break - chain.unshift(summary) - cursor = summary.parentId - } - return chain - } - - /** - * Lazily mint the scope + binding for an eligible session. Eligibility and - * prune share one predicate (decision 12): listed on the host — a scope is - * born when its session enters the client's view (list mirror row from the - * baseline pull, a create() echo, or the session-added frame) and dies with - * the prune when the row leaves. - */ - private resolve(id: SessionId): ScopeRecord | undefined { - const existing = this.scopes.get(id) - if (existing !== undefined) return existing - if (!this.eligible(id)) return undefined - const { fiber, ctx } = createScope(this.rootCtx, id) - const session = this.manager.get(id) - // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); - // mint and bind are one step so a live scope record implies a bound actx. - session.bindScope(ctx) - const binding: SessionBinding = { sessionId: id, session, ctx } - const record: ScopeRecord = { - fiber, - ctx, - binding, - // Sources are bare observables; React binds selector hooks at its own seam. - provideInfo: this.materializeProvideInfo(binding), - } - this.scopes.set(id, record) - return record - } - - /** The one aliveness predicate shared by scope mint and prune: host-listed. */ - private eligible(id: SessionId): boolean { - return this.list.getSnapshot().byId[id] !== undefined - } - - /** Project the manager's list snapshot into the store (title derivation is display-only). */ - private projectList(): void { - const { items, current, phase } = this.manager.getListSnapshot() - const ids: SessionId[] = [] - const byId: Record = {} - for (const entry of items) { - ids.push(entry.sessionId) - byId[entry.sessionId] = { - id: entry.sessionId, - displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), - running: entry.running, - blank: entry.blank, - updatedAt: entry.updatedAt, - ...(entry.title !== undefined ? { title: entry.title } : {}), - ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), - ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), - } - } - const persisted = this.selection.getSnapshot().sessionId - // No current (cleared, or masked gap) wipes the persisted cell — a reload - // stays on empty; the in-memory selection still resurfaces a masked id. - if (current === undefined) { - if (persisted !== undefined) this.selection.set({}) - } else if (byId[current] !== undefined && persisted !== current) { - this.selection.set({ sessionId: current }) - } - this.list.set({ ids, byId, current, phase }) - this.pruneScopes(byId) - } - - /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ - private pruneScopes(byId: Record): void { - void byId - for (const [id, record] of this.scopes) { - if (this.eligible(id)) continue - if (id === this.watched) { - this.deferredRemovals.add(id) - continue - } - this.scopes.delete(id) - this.deferredRemovals.delete(id) - this.dropScope(id, record) - } - } - - /** - * One teardown for the whole per-session axis (decision 12): the scope - * fiber (cascading every actx-registered effect: input shell, slash - * controller, popup, plugin stores, listeners), the session-keyed slot - * stores, and the Session instance itself — the host session log is the - * durable truth, a reopen lazily rebuilds and backfills via open(). - */ - private dropScope(id: SessionId, record: ScopeRecord): void { - void record.fiber.dispose() - // Release the Session's dispatch point with the scope it belongs to (a - // surviving instance — the live Intent — rebinds when resolve re-mints). - record.binding.session.unbindScope() - // Optional lookup: slots and sessions are sibling services with no - // declared dependency; a slots-less boot (object-layer tests) skips. - this.rootCtx.get('slots')?.pruneStoreScope(id) - this.manager.drop(id) - } - - /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */ - private sweepDeferred(): void { - for (const id of [...this.deferredRemovals]) { - /* v8 ignore next -- defensive: only the staged id ever defers, and every - * stage move sweeps first, so the set cannot contain the id the stage just - * moved to; kept as a guard against future extra sweep call sites. */ - if (id === this.watched) continue - // Eligible again? (A re-added id cancels the deferred teardown.) - if (this.eligible(id)) { - this.deferredRemovals.delete(id) - continue - } - const record = this.scopes.get(id) - this.deferredRemovals.delete(id) - /* v8 ignore next -- defensive: prune deletes a scope and its deferral - * together, so a deferred id always still owns its record; kept so a - * future teardown path cannot double-dispose. */ - if (record !== undefined) { - this.scopes.delete(id) - this.dropScope(id, record) - } - } - } -} diff --git a/packages/client/ui-workspace/src/client/index.ts.orig b/packages/client/ui-workspace/src/client/index.ts.orig deleted file mode 100644 index 7b5823cc39..0000000000 --- a/packages/client/ui-workspace/src/client/index.ts.orig +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills - * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), - * and WorkspacePicker fills the conversation hero's picker hole - * (`conversation.hero.workspace` — both hero forms). Both read real Host - * Workspaces through the global useWorkspaces hook. Export discipline: - * packages/client/AGENTS.md. - */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' -import { createWorkspaceViewStore } from './stores.ts' -import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' -import { WorkspacePicker } from './WorkspacePicker.tsx' - -export type { - WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, -} from './contract/slots.ts' - -/** - * Required services (cordis fiber inject). The target slots are declared by - * the ui-sidebar / ui-conversation applies, whose activation order relative - * to this one is NOT constrained: dshClient.inject edges are informational - * (loading/prefetch metadata, never apply sequencing) and neither owner - * provides a waitable service. apply therefore registers via - * declaration-aware deferral instead of assuming order. - */ -export const inject = ['slots', 'sessions', 'workspaces'] - -/** - * Register the browser and picker once their slot declarations are on the - * ledger. Inject factories return plain callbacks; data reads use the - * framework's global hooks. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - const browserInjected = (): WorkspaceBrowserInjected => ({ - // With a workspace: materialize (reuse-or-create the blank session) and - // navigate. Without one: clear the selection — the layout's empty seat - // shows the New Session pure view state and the user picks there. - startSession: (workspaceId) => { - if (workspaceId === undefined) { - ctx.sessions.clear() - return - } - void ctx.workspaces.connectWorkspace(workspaceId).then( - (sessionId) => { ctx.sessions.open(sessionId) }, - (reason: unknown) => { console.warn('new session failed:', reason) }, - ) - }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, - renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, - insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { - await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) - }, - createWorkspace: input => ctx.workspaces.create(input), - }) - const pickerInjected = (): WorkspacePickerInjected => ({ - createWorkspace: input => ctx.workspaces.create(input), - }) - // Declaration-aware registration: each owner's declaring apply may activate - // after this one (entry activation order is unconstrained), and a register - // into an undeclared slot throws. Register once the declaration is on the - // ledger; the subscription also re-registers after an HMR collapse - // re-declares the slot (the cascade disposed our entry with it). - ctx.effect(() => { - const registrations = [ - { - name: 'sidebar.workspaces' as const, - component: WorkspaceBrowser, - register: () => ctx.slots.register( - { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, - WorkspaceBrowser, - ), - }, - { - name: 'conversation.hero.workspace' as const, - component: WorkspacePicker, - register: () => ctx.slots.register( - { name: 'conversation.hero.workspace', inject: pickerInjected }, - WorkspacePicker, - ), - }, - ] - const disposers = new Map void>() - const tryRegister = (entry: (typeof registrations)[number]): void => { - if (ctx.slots.spec(entry.name) === undefined) return - if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return - disposers.set(entry.name, entry.register()) - } - const unsubscribers = registrations.map(entry => - ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) - for (const entry of registrations) tryRegister(entry) - return () => { - for (const unsubscribe of unsubscribers) unsubscribe() - for (const dispose of disposers.values()) dispose() - } - }, 'ui-workspace: browser + picker registrations') -} diff --git a/packages/client/ui-workspace/src/client/tree.ts.orig b/packages/client/ui-workspace/src/client/tree.ts.orig deleted file mode 100644 index 6d3126fcd1..0000000000 --- a/packages/client/ui-workspace/src/client/tree.ts.orig +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Derives the workspace browser tree from Host Workspace order and membership. - * Unassigned Sessions trail under Ungrouped; blank Sessions remain visible. - */ -import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' - -/** Group key for Sessions outside every Workspace. */ -export const UNGROUPED_KEY = '' - -/** Display label for the ungrouped bucket row. */ -export const UNGROUPED_LABEL = 'Ungrouped' - -/** One session node of a group's visible tree (34px row; children render indented one step). */ -export interface SessionNode { - id: SessionId - title: string - /** Visible children, already expansion/search-filtered (empty when folded). */ - children: readonly SessionNode[] - /** The session HAS children in the data (the twist renders even while folded). */ - hasChildren: boolean - expanded: boolean - running: boolean - updatedAt: number -} - -/** One workspace group section: header row facts + the visible session tree. */ -export interface GroupNode { - /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ - key: string - /** Backing Workspace id; absent only for the ungrouped bucket. */ - workspaceId: WorkspaceId | undefined - cwd: string | undefined - label: string - /** Total visible sessions in the group. */ - sessionCount: number - expanded: boolean - /** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */ - containsCurrent: boolean - /** Visible roots (empty while the group is folded). */ - sessions: readonly SessionNode[] -} - -/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */ -export interface TreeView { - expandedProjects: readonly string[] - expandedSessions: readonly string[] - query: string -} - -interface Group { - key: string - workspaceId: WorkspaceId | undefined - cwd: string | undefined - label: string - summaries: Map - roots: SessionId[] - children: Map -} - -/** - * Directory display label: basename of the path (both separators accepted). - * Ungrouped-bucket fallback for surfaces without a workspace title. - * @param cwd - directory path, or undefined for the ungrouped bucket. - * @returns basename, the raw cwd when it has no basename, or the ungrouped label. - */ -export function projectLabel(cwd: string | undefined): string { - if (cwd === undefined || cwd === '') return UNGROUPED_LABEL - const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() - return base !== undefined && base !== '' ? base : cwd -} - -/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */ -function byRecency(a: SessionSummary, b: SessionSummary): number { - if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt - return a.id < b.id ? -1 : 1 -} - -/** Build one group's parent/child tree from an ordered member list. */ -function buildGroup( - key: string, - workspaceId: WorkspaceId | undefined, - cwd: string | undefined, - label: string, - members: readonly SessionSummary[], - order: 'account' | 'recency', -): Group { - const summaries = new Map(members.map(m => [m.id, m])) - const children = new Map() - const roots: SessionSummary[] = [] - for (const m of members) { - // A session is a tree child only when its parent lives in the same - // group; cross-group or unknown parents degrade to group roots. - if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) { - const kids = children.get(m.parentId) - if (kids === undefined) children.set(m.parentId, [m.id]) - else kids.push(m.id) - } else { - roots.push(m) - } - } - // Workspace order is the member iteration order (workspace.sessionIds), so - // attached groups keep insertion order; Ungrouped sorts by recency. - if (order === 'recency') { - roots.sort(byRecency) - for (const kids of children.values()) { - kids.sort((a, b) => { - const sa = summaries.get(a) - const sb = summaries.get(b) - /* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */ - if (sa === undefined || sb === undefined) return 0 - return byRecency(sa, sb) - }) - } - } - const rootIds = roots.map(r => r.id) - // parentId cycles (host bug) leave members unreachable from any root; - // surface them as extra roots — the flatten walk's visited set stops - // loops. Each node sits in at most one kids list and roots have no - // in-group parent, so the scan pushes every reachable node exactly once. - const reachable = new Set(rootIds) - const stack = [...rootIds] - while (stack.length > 0) { - const top = stack.pop() - /* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */ - if (top === undefined) break - for (const kid of children.get(top) ?? []) { - reachable.add(kid) - stack.push(kid) - } - } - for (const m of members) { - if (!reachable.has(m.id)) rootIds.push(m.id) - } - return { key, workspaceId, cwd, label, summaries, roots: rootIds, children } -} - -/** - * Group Sessions by Host Workspace: one group per entity in stable Host - * order, with members resolved from sessionIds in their stored order. Sessions - * outside every Workspace trail in the recency-ordered Ungrouped bucket. - */ -function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] { - const groups: Group[] = [] - const accounted = new Set() - for (const workspace of workspaces) { - const members: SessionSummary[] = [] - for (const id of workspace.sessionIds) { - const summary = list.byId[id] - if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands - accounted.add(id) - members.push(summary) - } - groups.push(buildGroup( - workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account', - )) - } - const stray = list.ids - .map(id => list.byId[id]) - .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id)) - if (stray.length > 0) { - groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) - } - return groups -} - -function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode { - return { - id: s.id, - title: s.displayTitle, - children, - hasChildren, - expanded, - running: s.running, - updatedAt: s.updatedAt, - } -} - -function buildVisible(g: Group, expandedSessions: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = g.children.get(id) ?? [] - const expanded = expandedSessions.has(id) - const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : [] - return sessionNode(s, children, kids.length > 0, expanded) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** Matched sessions plus their ancestor chains (forced visible under search). */ -function searchVisible(g: Group, q: string): Set { - const visible = new Set() - for (const m of g.summaries.values()) { - if (!m.displayTitle.toLowerCase().includes(q)) continue - let cur: SessionSummary | undefined = m - while (cur !== undefined && !visible.has(cur.id)) { - visible.add(cur.id) - cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined - } - } - return visible -} - -function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id) || !visible.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid)) - const children = kids.map(walk).filter((n): n is SessionNode => n !== null) - return sessionNode(s, children, kids.length > 0, kids.length > 0) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** - * Derive the nested workspace browser group structure. - * - * Normal mode: every group shows; sessions populate under expanded groups, - * descending only into expanded sessions. Search mode (non-blank query, - * case-insensitive display-title substring): expansion state is ignored — - * matched sessions and their ancestor chains are forced visible, groups - * without a display-title or label hit are dropped, and a label-only hit - * keeps the bare group header. Blank sessions are excluded everywhere. - * @param list - sessions list snapshot (`current` feeds containsCurrent). - * @param workspaces - real workspaces in stable Host order. - * @param view - local expansion arrays and search query. - * @returns group sections in render order. - */ -export function deriveGroups( - list: SessionListState, - workspaces: readonly WorkspaceView[], - view: TreeView, -): GroupNode[] { - const q = view.query.trim().toLowerCase() - const expandedProjects = new Set(view.expandedProjects) - const expandedSessions = new Set(view.expandedSessions) - const currentGroup = list.current === undefined - ? undefined - : (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)) { - if (q === '') { - const expanded = expandedProjects.has(g.key) - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded, - containsCurrent: g.key === currentGroup, - sessions: expanded ? buildVisible(g, expandedSessions) : [], - }) - } else { - const visible = searchVisible(g, q) - if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded: visible.size > 0, - containsCurrent: g.key === currentGroup, - sessions: buildSearch(g, visible), - }) - } - } - return groups -} - -/** - * Derive the flat session list ("In one list" mode): every session — fork - * children included — as a top-level row, strictly newest-first. No grouping, - * no parent/child adjacency; rows reuse SessionNode with children always - * empty so the renderer stays branch-free. Search mode filters by - * case-insensitive display-title substring. - * @param list - sessions list snapshot. - * @param view - the search query (expansion state does not apply). - * @returns flat rows in render order. - */ -export function deriveFlat(list: SessionListState, view: Pick): SessionNode[] { - const q = view.query.trim().toLowerCase() - const rows: SessionSummary[] = [] - for (const id of list.ids) { - const s = list.byId[id] - if (s === undefined) continue - if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue - rows.push(s) - } - rows.sort(byRecency) - return rows.map(s => sessionNode(s, [], false, false)) -} - -/** - * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). - * @param updatedAt - epoch ms of the session's last activity. - * @param now - current epoch ms (injected for pure rendering). - * @returns the row's trailing time label. - */ -export function formatRelativeTime(updatedAt: number, now: number): string { - const MIN = 60_000 - const HOUR = 3_600_000 - const DAY = 86_400_000 - const diff = Math.max(0, now - updatedAt) - if (diff < MIN) return 'now' - if (diff < HOUR) return `${Math.floor(diff / MIN)}min` - if (diff < DAY) return `${Math.floor(diff / HOUR)}h` - if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d` - if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo` - return `${Math.floor(diff / (365 * DAY))}y` -}