Browse Source

fix: remove orig

imccyu 2 months ago
parent
commit
e8cbf6cc72

+ 0 - 590
packages/client/runtime/src/client/sessions/service.ts.orig

@@ -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<SessionId, SessionSummary>
-  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<string, HostObservable<unknown>>
-  /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
-  props?: Record<string, unknown>
-}
-
-/**
- * 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<SessionListState>
-  /** 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<SessionId, ScopeRecord>()
-  /** 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<SessionId>()
-
-  /**
-   * @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<SessionListState>({
-      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<Name>` 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<string, undefined> = {}
-    const props: Record<string, undefined> = {}
-    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<string, HostObservable<unknown>> = {}
-    const props: Record<string, unknown> = {}
-    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<void> {
-    return this.manager.refreshList()
-  }
-
-  /**
-   * Route a mux stream envelope into the Session object layer.
-   * @param envelope - validated mux stream envelope.
-   */
-  handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[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<SessionManager['handleHostEnvelope']>[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<SessionId> {
-    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<SessionId, SessionSummary> = {}
-    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<SessionId, SessionSummary>): 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)
-      }
-    }
-  }
-}

+ 0 - 98
packages/client/ui-workspace/src/client/index.ts.orig

@@ -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<string, () => 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')
-}

+ 0 - 321
packages/client/ui-workspace/src/client/tree.ts.orig

@@ -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<SessionId, SessionSummary>
-  roots: SessionId[]
-  children: Map<SessionId, SessionId[]>
-}
-
-/**
- * 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<SessionId, SessionId[]>()
-  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<SessionId>(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<SessionId>()
-  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<string>): SessionNode[] {
-  const visited = new Set<SessionId>()
-  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<SessionId> {
-  const visible = new Set<SessionId>()
-  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<SessionId>): SessionNode[] {
-  const visited = new Set<SessionId>()
-  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<TreeView, 'query'>): 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`
-}