/** * What the conversation column is showing — which the panels sit beside, and * which is NOT the same question as what is selected. * * The shipped answer, `sessions.current`, is right for as long as every mode's * column is the web conversation. It stops being right the moment one is not: * `omdsh-codemode`'s column is a terminal, and it deliberately never selects * the conversation that terminal drives — a selected conversation is one the * web host would resume, on a log another process owns. So the selection * stays on whatever was open behind the terminal, and a file tree keyed on it * would sit beside a terminal in one project describing another. * * `@omdsh-plugins/omdsh-basemode` publishes the honest answer as `sessionModes.column`: * the active mode's own scope when it declares one, the selection otherwise. * This module is the follower — it mirrors that when the mode system is * composed, and falls back to the selection when it is not, so a profile with * no modes at all behaves exactly as this plugin always did. * @module @omdsh-plugins/omdsh-sidepanel/src/client/column */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' /** * Service name the mode registry is published under, by * `@omdsh-plugins/omdsh-basemode`. * * A literal rather than an import, for the reason `shortcut.ts` mirrors its * own: cordis binds services by name at runtime, and a cross-plugin value * import is a client-bundle purity error. The name is a wire word the two * packages share, not a symbol one owns. */ export const SESSION_MODES = 'sessionModes' /** What the conversation column is showing; mirrors omdsh-basemode's own type. */ export interface ColumnScope { /** The conversation on screen — not necessarily the selected one. */ readonly sessionId: string /** Its directory, when anything knows one. */ readonly cwd: string | undefined } /** As much of the mode registry as a surface beside the column needs. */ export interface IModeColumn { /** What the column is showing, as the active mode reports it. */ readonly column: ObservableSnapshot } /** * The mode registry as this plugin also reads it for Code mode's *presence*, * not only for what the column shows. * * `store` is the switch's segment list. A `code` row on it is how this plugin * knows Code mode is composed, without importing that package: the id is a * wire word (see {@link CODE_SEGMENT} in shared.ts). */ export interface ISessionModes extends IModeColumn { /** What the switch renders, in display order. */ readonly store: ObservableSnapshot } /** * The selected conversation, as a column scope. The fallback, and what this * plugin read directly before the mode system existed. * @param sessions - the live session list snapshot. * @returns the selection and its directory, or undefined for no selection. */ export function fromSelection(sessions: SessionListState): ColumnScope | undefined { const current = sessions.current if (current === undefined) return undefined return { sessionId: current, cwd: sessions.byId[current]?.cwd } } /** Whether two scopes name the same screen. */ function same(left: ColumnScope | undefined, right: ColumnScope | undefined): boolean { return left?.sessionId === right?.sessionId && left?.cwd === right?.cwd } /** * A store reporting what the column shows, following the mode system when one * arrives and the selection until then. * * Both inputs are pushed rather than subscribed to here: the caller owns the * lifetimes (a plugin effect for the session list, a restricted fiber for the * registry), and a mode system that unloads at runtime has to hand the answer * back to the selection rather than freeze on its last one. */ export class ColumnTracker { /** What the panels read. */ readonly store: SnapshotStore = createSnapshotStore(undefined) private selection: ColumnScope | undefined private modes: IModeColumn | undefined /** * Report the current selection. * @param scope - the selected conversation, or undefined for none. */ setSelection(scope: ColumnScope | undefined): void { this.selection = scope this.publish() } /** * Follow a mode registry, until the returned release is called. * @param modes - the resolved registry. * @returns the release, which hands the answer back to the selection. */ follow(modes: IModeColumn): () => void { this.modes = modes const stop = modes.column.subscribe(() => { this.publish() }) this.publish() return () => { stop() if (this.modes === modes) this.modes = undefined this.publish() } } /** Publish, but only when the screen actually moved. */ private publish(): void { const next = this.modes?.column.getSnapshot() ?? this.selection if (same(this.store.getSnapshot(), next)) return this.store.set(next) } }