fix: remove orig
This commit is contained in:
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
}
|
|
||||||
@@ -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`
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user