From b30b913c0409bffc3038febea790bd57606eb082 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 12 Jul 2026 20:19:24 +0900 Subject: [PATCH] Make the chat session runtime the per-panel composition owner --- docs/development.md | 6 ++ .../{session-graph.ts => session-runtime.ts} | 63 +++++++++++---- src/features/chat/host/session.ts | 81 +++++++++---------- .../host/session/connected-client-resolver.ts | 22 ----- .../host/connected-client-resolver.test.ts | 31 ------- ...-graph.test.ts => session-runtime.test.ts} | 70 ++++++++-------- 6 files changed, 127 insertions(+), 146 deletions(-) rename src/features/chat/host/{session-graph.ts => session-runtime.ts} (84%) delete mode 100644 src/features/chat/host/session/connected-client-resolver.ts delete mode 100644 tests/features/chat/host/connected-client-resolver.test.ts rename tests/features/chat/host/{session-graph.test.ts => session-runtime.test.ts} (85%) diff --git a/docs/development.md b/docs/development.md index 1e836851..fecdb64e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -47,8 +47,14 @@ Chat application workflows should receive chat-owned contracts, not root `src/ap Thread workflows should likewise depend on contracts under `src/features/threads/workflows/`. Keep thread RPC sequencing, client continuity, persisted transcript reads, and Codex-backed title generation in `src/features/threads/app-server/` adapters. +Keep shared thread query records as raw last-known-good app-server snapshots. `ThreadCatalog` owns lifecycle-event overlays and publishes the resulting projection immediately; chat panels, the Threads view, and settings consume that projection without writing optimistic state back into the query cache. + +Selection rewrite sessions should depend on the feature-owned transport contract. Keep executable-path and vault-path wiring, short-lived app-server lifecycle, runtime override resolution, and server-request policy in the selection rewrite app-server adapter. + Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the panel read model adapter. Use Preact Signals only in chat panel rendering adapters such as the shell read model and surface projections. When a surface needs fewer dependencies, narrow the read model instead of passing broad reducer slices. +Each `ChatPanelSession` owns one concrete `ChatPanelSessionRuntime`. That runtime is the per-leaf composition owner for the connection, responder registry, controllers, presenters, shared subscriptions, and disposal ordering; do not add a parallel session graph or late-bound connection bootstrap registry. + Chat feature dependencies should flow from pure workflow and meaning code toward owned adapters and render surfaces. Lower layers must not reach into host/session wiring, panel internals, or UI implementation details. Chat modules should not import `src/workspace/` directly; workspace operations enter chat through host contracts, while workspace modules may coordinate concrete Obsidian chat views. diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-runtime.ts similarity index 84% rename from src/features/chat/host/session-graph.ts rename to src/features/chat/host/session-runtime.ts index a1206012..59da9d86 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-runtime.ts @@ -24,11 +24,10 @@ import { type ChatPanelShellBundle, createShellBundle } from "./bundles/shell-bu import { createThreadActionBundle, createThreadFoundation, createThreadLifecycleBundle } from "./bundles/thread-bundle"; import { createTurnBundle } from "./bundles/turn-bundle"; import type { ChatPanelEnvironment } from "./contracts"; -import { createConnectedClientResolver } from "./session/connected-client-resolver"; import type { ChatViewDeferredTasks } from "./session/deferred-work"; import { type ChatPanelSharedStateBinding, createChatPanelSharedStateBinding } from "./session/shared-state-binding"; -export interface ChatPanelSessionGraph { +interface ChatPanelSessionRuntimeParts { connection: { manager: ConnectionManager; actions: ChatPanelConnectionBundle["connection"]["actions"]; @@ -48,13 +47,13 @@ export interface ChatPanelSessionGraph { reconnect(): Promise; refreshSharedThreads(): Promise; startNewThread(): Promise; - dispose(): void; }; runtime: { sharedState: ChatPanelSharedStateBinding; refreshLiveState(): void; deferLiveStateRefresh(): void; }; + disposeOwnedResources: () => void; } interface ChatPanelSessionStatus { @@ -63,7 +62,7 @@ interface ChatPanelSessionStatus { addStructuredSystemMessage: (text: string, details: ThreadStreamNoticeSection[]) => void; } -interface ChatPanelSessionGraphHost { +interface ChatPanelSessionRuntimeHost { environment: ChatPanelEnvironment; stateStore: ChatStateStore; deferredTasks: ChatViewDeferredTasks; @@ -73,17 +72,54 @@ interface ChatPanelSessionGraphHost { viewWindow: () => Window; } -export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): ChatPanelSessionGraph { +export class ChatPanelSessionRuntime { + readonly connection: ChatPanelSessionRuntimeParts["connection"]; + readonly thread: ChatPanelSessionRuntimeParts["thread"]; + readonly composer: ChatPanelSessionRuntimeParts["composer"]; + readonly shell: ChatPanelSessionRuntimeParts["shell"]; + readonly actions: ChatPanelSessionRuntimeParts["actions"]; + readonly runtime: ChatPanelSessionRuntimeParts["runtime"]; + private readonly disposeOwnedResources: ChatPanelSessionRuntimeParts["disposeOwnedResources"]; + + constructor(private readonly host: ChatPanelSessionRuntimeHost) { + const parts = composeChatPanelSessionRuntime(host); + this.connection = parts.connection; + this.thread = parts.thread; + this.composer = parts.composer; + this.shell = parts.shell; + this.actions = parts.actions; + this.runtime = parts.runtime; + this.disposeOwnedResources = parts.disposeOwnedResources; + } + + async dispose(unmount: () => void): Promise { + this.connection.actions.invalidate(); + this.actions.invalidateThreadWork(); + this.host.deferredTasks.clearAll(); + this.runtime.sharedState.unsubscribe(); + await this.thread.ephemeral.dispose(); + this.disposeOwnedResources(); + unmount(); + this.connection.manager.disconnect(); + this.runtime.refreshLiveState(); + this.runtime.deferLiveStateRefresh(); + } +} + +function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): ChatPanelSessionRuntimeParts { const { environment, stateStore } = host; const localItemIds = createLocalIdSource(); const connection = createConnectionManager(environment); const currentClient = () => connection.currentClient(); - const connectedClient = createConnectedClientResolver(currentClient); + let ensureConnected = (): Promise => Promise.reject(new Error("Codex app-server connection actions are not initialized.")); const appServer = createChatAppServerGateway({ codexPath: () => environment.plugin.settingsRef.settings.codexPath(), vaultPath: environment.plugin.settingsRef.vaultPath, currentClient, - connectedClient: () => connectedClient.resolve(), + connectedClient: async () => { + await ensureConnected(); + return currentClient(); + }, }); const status = createSessionStatus(stateStore, localItemIds); const refreshTabHeader = () => { @@ -131,8 +167,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch connection: { actions: connectionActions }, inboundHandler, } = connectionBundle; - const ensureConnected = () => connectionActions.ensureConnected(); - connectedClient.bindEnsureConnected(ensureConnected); + ensureConnected = () => connectionActions.ensureConnected(); const refreshActiveThreads = () => connectionActions.refreshActiveThreads(); const runtime = createRuntimeBundle(host, { connection, @@ -281,17 +316,17 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch reconnect, refreshSharedThreads, startNewThread: () => threadActions.navigation.startNewThread(), - dispose: () => { - connectionBundle.invalidateConnectionScope(); - shell.dispose(); - composerController.dispose(); - }, }, runtime: { sharedState, refreshLiveState, deferLiveStateRefresh, }, + disposeOwnedResources: () => { + connectionBundle.invalidateConnectionScope(); + shell.dispose(); + composerController.dispose(); + }, }; } diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 9130515a..3048ee25 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -10,11 +10,11 @@ import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom" import { type ChatThreadStreamScrollBinding, createChatThreadStreamScrollBinding } from "../panel/thread-stream-scroll-binding"; import type { ChatPanelEnvironment, ChatPanelHandle, ChatWorkspacePanelSnapshot, ChatWorkspacePanelTurnLifecycle } from "./contracts"; import { type ChatViewDeferredTasks, createChatViewDeferredTasks } from "./session/deferred-work"; -import { type ChatPanelSessionGraph, createChatPanelSessionGraph } from "./session-graph"; +import { ChatPanelSessionRuntime } from "./session-runtime"; export class ChatPanelSession implements ChatPanelHandle { private readonly stateStore: ChatStateStore = createChatStateStore(); - private readonly graph: ChatPanelSessionGraph; + private readonly runtime: ChatPanelSessionRuntime; private readonly deferredTasks: ChatViewDeferredTasks; private readonly resumeWork = new ChatResumeWorkTracker(); @@ -27,7 +27,7 @@ export class ChatPanelSession implements ChatPanelHandle { constructor(private readonly environment: ChatPanelEnvironment) { this.observedAppServerContext = this.currentAppServerContext(); this.deferredTasks = createChatViewDeferredTasks(() => this.viewWindow()); - this.graph = this.createSessionGraph(); + this.runtime = this.createSessionRuntime(); } displayTitle(): string { @@ -63,8 +63,8 @@ export class ChatPanelSession implements ChatPanelHandle { const ephemeralSource = parseEphemeralSourceState(state); if (ephemeralSource) { this.ephemeralSourcePlaceholder = ephemeralSource; - this.graph.actions.invalidateThreadWork(); - this.graph.thread.restoration.clear(); + this.runtime.actions.invalidateThreadWork(); + this.runtime.thread.restoration.clear(); this.stateStore.dispatch({ type: "thread-stream/system-item-added", item: { @@ -79,12 +79,12 @@ export class ChatPanelSession implements ChatPanelHandle { this.ephemeralSourcePlaceholder = null; const restoredThread = parseRestoredThreadState(state); if (restoredThread) { - this.graph.thread.restoration.restore(restoredThread); + this.runtime.thread.restoration.restore(restoredThread); return; } - this.graph.actions.invalidateThreadWork(); - this.graph.thread.restoration.clear(); + this.runtime.actions.invalidateThreadWork(); + this.runtime.thread.restoration.clear(); this.scheduleWarmup(); } @@ -92,18 +92,18 @@ export class ChatPanelSession implements ChatPanelHandle { const nextContext = this.currentAppServerContext(); if (!appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) { this.observedAppServerContext = nextContext; - void this.graph.actions.reconnect(); - this.graph.runtime.sharedState.applyCached(); + void this.runtime.actions.reconnect(); + this.runtime.runtime.sharedState.applyCached(); } this.mountOrRepairShell(); } refreshSharedThreads(): Promise { - return this.graph.actions.refreshSharedThreads(); + return this.runtime.actions.refreshSharedThreads(); } canServeAppServerContext(context: AppServerQueryContext): boolean { - const connectionContext = this.graph.connection.manager.currentConnectionContext(); + const connectionContext = this.runtime.connection.manager.currentConnectionContext(); return Boolean( connectionContext && appServerQueryContextMatches( @@ -117,10 +117,10 @@ export class ChatPanelSession implements ChatPanelHandle { } async runWithAppServerClient(operation: (client: AppServerClient) => Promise): Promise { - const client = this.graph.connection.manager.currentClient(); + const client = this.runtime.connection.manager.currentClient(); if (!client) throw new Error("Codex app-server is not connected."); const result = await operation(client); - if (this.graph.connection.manager.currentClient() !== client) { + if (this.runtime.connection.manager.currentClient() !== client) { throw new Error("Codex app-server connection changed while loading shared queries."); } return result; @@ -134,20 +134,20 @@ export class ChatPanelSession implements ChatPanelHandle { turnLifecycle: openPanelTurnLifecycle(this.state.turn.lifecycle), pendingRequests, hasComposerDraft: this.state.composer.draft.trim().length > 0, - connected: this.graph.connection.manager.isConnected(), + connected: this.runtime.connection.manager.isConnected(), }; } async openThread(threadId: string): Promise { - if (!(await this.graph.thread.ephemeral.prepareForPersistentNavigation())) return; + if (!(await this.runtime.thread.ephemeral.prepareForPersistentNavigation())) return; this.ephemeralSourcePlaceholder = null; - await this.graph.thread.resume.resumeThread(threadId); + await this.runtime.thread.resume.resumeThread(threadId); this.focusComposer(); } async focusThread(threadId: string | null = null): Promise { const restoredThreadId = this.restoredThread()?.threadId ?? null; - if ((threadId && this.graph.thread.restoration.isPending(threadId)) || (!threadId && restoredThreadId)) { + if ((threadId && this.runtime.thread.restoration.isPending(threadId)) || (!threadId && restoredThreadId)) { await this.ensureRestoredThreadLoaded(); } this.focusComposer(); @@ -158,16 +158,16 @@ export class ChatPanelSession implements ChatPanelHandle { } focusComposer(): void { - this.graph.composer.controller.focusComposer(); + this.runtime.composer.controller.focusComposer(); } applyThreadArchived(threadId: string): void { - this.graph.thread.identity.applyThreadArchiveToActiveIdentity(threadId); + this.runtime.thread.identity.applyThreadArchiveToActiveIdentity(threadId); } applyThreadRenamed(threadId: string, name: string | null): void { const previousRestoredExplicitName = this.restoredThread()?.explicitName ?? null; - this.graph.thread.identity.applyThreadRenameToActiveIdentity(threadId, name); + this.runtime.thread.identity.applyThreadRenameToActiveIdentity(threadId, name); if (this.restoredThread()?.explicitName !== previousRestoredExplicitName) { this.mountOrRepairShell(); } @@ -179,7 +179,7 @@ export class ChatPanelSession implements ChatPanelHandle { this.environment.obsidian.registerPointerDown((event) => { this.closeToolbarPanelOnOutsidePointer(event); }); - this.graph.runtime.sharedState.subscribe(); + this.runtime.runtime.sharedState.subscribe(); this.mountOrRepairShell(); this.scheduleWarmup(); } @@ -187,34 +187,27 @@ export class ChatPanelSession implements ChatPanelHandle { async close(): Promise { this.opened = false; this.closing = true; - this.graph.connection.actions.invalidate(); - this.graph.actions.invalidateThreadWork(); - this.deferredTasks.clearAll(); - this.graph.runtime.sharedState.unsubscribe(); - await this.graph.thread.ephemeral.dispose(); const panelRoot = this.environment.view.panelRoot(); - this.graph.actions.dispose(); - unmountChatPanelShell(panelRoot); - this.graph.connection.manager.disconnect(); - this.graph.runtime.refreshLiveState(); - this.graph.runtime.deferLiveStateRefresh(); + await this.runtime.dispose(() => { + unmountChatPanelShell(panelRoot); + }); } setComposerText(text: string): void { - this.graph.composer.controller.setDraft(text, { focus: true }); + this.runtime.composer.controller.setDraft(text, { focus: true }); } async connect(): Promise { - await this.graph.connection.actions.ensureConnected(); + await this.runtime.connection.actions.ensureConnected(); } async startNewThread(): Promise { - await this.graph.actions.startNewThread(); + await this.runtime.actions.startNewThread(); if (!this.state.activeThread.id) this.ephemeralSourcePlaceholder = null; } async openSideChat(input: { sourceThreadId: string; sourceThreadTitle: string | null }): Promise { - const opened = await this.graph.thread.ephemeral.open(input); + const opened = await this.runtime.thread.ephemeral.open(input); if (!opened) return false; this.ephemeralSourcePlaceholder = null; this.focusComposer(); @@ -231,17 +224,17 @@ export class ChatPanelSession implements ChatPanelHandle { renderChatPanelShell(root, { stateStore: this.stateStore, showToolbar: this.environment.plugin.settingsRef.settings.showToolbar(), - parts: this.graph.shell.parts, + parts: this.runtime.shell.parts, }); } private scheduleWarmup(): void { - const shouldWarmup = (): boolean => this.opened && !this.graph.connection.manager.isConnected(); + const shouldWarmup = (): boolean => this.opened && !this.runtime.connection.manager.isConnected(); if (!shouldWarmup()) return; this.deferredTasks.scheduleAppServerWarmup(() => { if (!shouldWarmup() || this.closing) return; - void this.graph.connection.actions.ensureConnected(); + void this.runtime.connection.actions.ensureConnected(); }); } @@ -257,7 +250,7 @@ export class ChatPanelSession implements ChatPanelHandle { } private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void { - this.graph.shell.closeToolbarPanelOnOutsidePointer(event); + this.runtime.shell.closeToolbarPanelOnOutsidePointer(event); } private activeThreadTitle(): string | null { @@ -272,7 +265,7 @@ export class ChatPanelSession implements ChatPanelHandle { } private restoredThread(): RestoredThreadPlaceholderState | null { - return this.graph.thread.restoration.placeholder(); + return this.runtime.thread.restoration.placeholder(); } private panelThreadId(): string | null { @@ -280,11 +273,11 @@ export class ChatPanelSession implements ChatPanelHandle { } private ensureRestoredThreadLoaded(): Promise { - return this.graph.thread.restoration.ensureLoaded((threadId) => this.graph.thread.resume.resumeThread(threadId)); + return this.runtime.thread.restoration.ensureLoaded((threadId) => this.runtime.thread.resume.resumeThread(threadId)); } - private createSessionGraph(): ChatPanelSessionGraph { - return createChatPanelSessionGraph({ + private createSessionRuntime(): ChatPanelSessionRuntime { + return new ChatPanelSessionRuntime({ environment: this.environment, stateStore: this.stateStore, deferredTasks: this.deferredTasks, diff --git a/src/features/chat/host/session/connected-client-resolver.ts b/src/features/chat/host/session/connected-client-resolver.ts deleted file mode 100644 index 88924e2d..00000000 --- a/src/features/chat/host/session/connected-client-resolver.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { AppServerClient } from "../../../../app-server/connection/client"; - -export interface ConnectedClientResolver { - resolve(): Promise; - bindEnsureConnected(ensureConnected: () => Promise): void; -} - -export function createConnectedClientResolver(currentClient: () => AppServerClient | null): ConnectedClientResolver { - let ensureConnected: (() => Promise) | null = null; - - return { - resolve: async () => { - if (!ensureConnected) throw new Error("Codex app-server connection actions are not initialized."); - await ensureConnected(); - return currentClient(); - }, - bindEnsureConnected: (nextEnsureConnected) => { - if (ensureConnected) throw new Error("Codex app-server connection actions are already initialized."); - ensureConnected = nextEnsureConnected; - }, - }; -} diff --git a/tests/features/chat/host/connected-client-resolver.test.ts b/tests/features/chat/host/connected-client-resolver.test.ts deleted file mode 100644 index 3f3d0da7..00000000 --- a/tests/features/chat/host/connected-client-resolver.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { AppServerClient } from "../../../../src/app-server/connection/client"; -import { createConnectedClientResolver } from "../../../../src/features/chat/host/session/connected-client-resolver"; - -describe("createConnectedClientResolver", () => { - it("connects before resolving the current client", async () => { - const client = {} as AppServerClient; - const currentClient = vi.fn(() => client); - const ensureConnected = vi.fn().mockResolvedValue(undefined); - const resolver = createConnectedClientResolver(currentClient); - resolver.bindEnsureConnected(ensureConnected); - - await expect(resolver.resolve()).resolves.toBe(client); - expect(ensureConnected).toHaveBeenCalledOnce(); - expect(currentClient).toHaveBeenCalledOnce(); - }); - - it("rejects use before the connection actions are bound", async () => { - const resolver = createConnectedClientResolver(() => null); - - await expect(resolver.resolve()).rejects.toThrow("connection actions are not initialized"); - }); - - it("rejects rebinding the connection actions", () => { - const resolver = createConnectedClientResolver(() => null); - resolver.bindEnsureConnected(vi.fn().mockResolvedValue(undefined)); - - expect(() => resolver.bindEnsureConnected(vi.fn().mockResolvedValue(undefined))).toThrow("connection actions are already initialized"); - }); -}); diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-runtime.test.ts similarity index 85% rename from tests/features/chat/host/session-graph.test.ts rename to tests/features/chat/host/session-runtime.test.ts index 69529c5c..c3c191c1 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-runtime.test.ts @@ -10,7 +10,7 @@ import { HistoryController } from "../../../../src/features/chat/application/thr import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/threads/resume-work"; import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/contracts"; import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/session/deferred-work"; -import { createChatPanelSessionGraph } from "../../../../src/features/chat/host/session-graph"; +import { ChatPanelSessionRuntime } from "../../../../src/features/chat/host/session-runtime"; import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller"; import { ThreadStreamPresenter } from "../../../../src/features/chat/panel/surface/thread-stream-presenter"; import { createChatThreadStreamScrollBinding } from "../../../../src/features/chat/panel/thread-stream-scroll-binding"; @@ -21,7 +21,7 @@ import { chatPanelSettingsAccess } from "../support/settings"; installObsidianDomShims(); -describe("createChatPanelSessionGraph actions", () => { +describe("ChatPanelSessionRuntime actions", () => { let panelRoot: HTMLElement; beforeEach(() => { @@ -33,21 +33,21 @@ describe("createChatPanelSessionGraph actions", () => { document.body.replaceChildren(); }); - it("invalidates thread work through the graph action", () => { - const { graph, resumeWork } = sessionGraphFixture(); + it("invalidates thread work through the runtime action", () => { + const { runtime, resumeWork } = sessionRuntimeFixture(); const resume = resumeWork.begin("thread-1"); const invalidateHistory = vi.spyOn(HistoryController.prototype, "invalidate"); - graph.actions.invalidateThreadWork(); + runtime.actions.invalidateThreadWork(); expect(resumeWork.isStale(resume)).toBe(true); expect(invalidateHistory).toHaveBeenCalledOnce(); }); - it("refreshes shared threads inside the graph connection bundle", async () => { + it("refreshes shared threads inside the runtime connection bundle", async () => { const thread = threadFixture({ id: "thread-1", preview: "From catalog" }); const refresh = vi.fn().mockResolvedValue([thread]); - const { graph, stateStore } = sessionGraphFixture({ + const { runtime, stateStore } = sessionRuntimeFixture({ environment: { plugin: { threadCatalog: { @@ -57,16 +57,16 @@ describe("createChatPanelSessionGraph actions", () => { }, }); - await graph.actions.refreshSharedThreads(); + await runtime.actions.refreshSharedThreads(); expect(refresh).toHaveBeenCalledOnce(); expect(stateStore.getState().threadList.listedThreads).toEqual([thread]); expect(stateStore.getState().threadList.threadsLoaded).toBe(true); }); - it("treats stale shared thread refreshes as graph-local no-ops", async () => { + it("treats stale shared thread refreshes as runtime-local no-ops", async () => { const refresh = vi.fn().mockRejectedValue(new StaleAppServerSharedQueryContextError()); - const { graph, stateStore } = sessionGraphFixture({ + const { runtime, stateStore } = sessionRuntimeFixture({ environment: { plugin: { threadCatalog: { @@ -76,7 +76,7 @@ describe("createChatPanelSessionGraph actions", () => { }, }); - await expect(graph.actions.refreshSharedThreads()).resolves.toBeUndefined(); + await expect(runtime.actions.refreshSharedThreads()).resolves.toBeUndefined(); expect(refresh).toHaveBeenCalledOnce(); expect(stateStore.getState().threadList.threadsLoaded).toBe(false); @@ -85,7 +85,7 @@ describe("createChatPanelSessionGraph actions", () => { it("applies cached shared state from the runtime binding", () => { const thread = threadFixture({ id: "thread-1", preview: "Cached thread" }); const model = modelFixture({ id: "model-1", model: "gpt-cached" }); - const { graph, stateStore } = sessionGraphFixture({ + const { runtime, stateStore } = sessionRuntimeFixture({ environment: { plugin: { threadCatalog: { @@ -98,7 +98,7 @@ describe("createChatPanelSessionGraph actions", () => { }, }); - graph.runtime.sharedState.applyCached(); + runtime.runtime.sharedState.applyCached(); expect(stateStore.getState().threadList.listedThreads).toEqual([thread]); expect(stateStore.getState().connection.availableModels).toEqual([model]); @@ -111,7 +111,7 @@ describe("createChatPanelSessionGraph actions", () => { const observeThreads = vi.fn(() => cleanupThreads); const observeMetadata = vi.fn(() => cleanupMetadata); const observeModels = vi.fn(() => cleanupModels); - const { graph } = sessionGraphFixture({ + const { runtime } = sessionRuntimeFixture({ environment: { plugin: { threadCatalog: { @@ -125,8 +125,8 @@ describe("createChatPanelSessionGraph actions", () => { }, }); - graph.runtime.sharedState.subscribe(); - graph.runtime.sharedState.unsubscribe(); + runtime.runtime.sharedState.subscribe(); + runtime.runtime.sharedState.unsubscribe(); expect(observeThreads).toHaveBeenCalledWith(expect.any(Function), { emitCurrent: false }); expect(observeMetadata).toHaveBeenCalledWith(expect.any(Function), { emitCurrent: false }); @@ -136,12 +136,12 @@ describe("createChatPanelSessionGraph actions", () => { expect(cleanupModels).toHaveBeenCalledOnce(); }); - it("starts a new thread from graph state and composer actions", async () => { + it("starts a new thread from runtime state and composer actions", async () => { const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined); const refreshLiveState = vi.fn(); const requestWorkspaceLayoutSave = vi.fn(); const refreshTabHeader = vi.fn(); - const { graph, stateStore } = sessionGraphFixture({ + const { runtime, stateStore } = sessionRuntimeFixture({ environment: { obsidian: { requestWorkspaceLayoutSave, @@ -173,7 +173,7 @@ describe("createChatPanelSessionGraph actions", () => { }); stateStore.dispatch({ type: "ui/panel-set", panel: "history" }); - await graph.actions.startNewThread(); + await runtime.actions.startNewThread(); expect(stateStore.getState().activeThread.id).toBeNull(); expect(stateStore.getState().ui.toolbarPanel).toBeNull(); @@ -186,21 +186,21 @@ describe("createChatPanelSessionGraph actions", () => { it("does not clear the current thread while a turn is busy", async () => { const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined); - const { graph, stateStore } = sessionGraphFixture(); + const { runtime, stateStore } = sessionRuntimeFixture(); stateStore.dispatch({ type: "turn/started", threadId: "thread-1", turnId: "turn-1", }); - await graph.actions.startNewThread(); + await runtime.actions.startNewThread(); expect(stateStore.getState().activeThread.id).toBe("thread-1"); expect(focusComposer).not.toHaveBeenCalled(); }); - it("wires reconnect cleanup through the graph toolbar action", async () => { - const { graph, stateStore, deferredTasks } = sessionGraphFixture(); + it("wires reconnect cleanup through the runtime toolbar action", async () => { + const { runtime, stateStore, deferredTasks } = sessionRuntimeFixture(); stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, @@ -216,13 +216,13 @@ describe("createChatPanelSessionGraph actions", () => { serviceTier: null, approvalsReviewer: null, }); - const invalidateConnection = vi.spyOn(graph.connection.actions, "invalidate"); + const invalidateConnection = vi.spyOn(runtime.connection.actions, "invalidate"); const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics"); - const resetConnection = vi.spyOn(graph.connection.manager, "resetConnection"); - const ensureConnected = vi.spyOn(graph.connection.actions, "ensureConnected").mockResolvedValue(undefined); - const resumeThread = vi.spyOn(graph.thread.resume, "resumeThread").mockResolvedValue(undefined); + const resetConnection = vi.spyOn(runtime.connection.manager, "resetConnection"); + const ensureConnected = vi.spyOn(runtime.connection.actions, "ensureConnected").mockResolvedValue(undefined); + const resumeThread = vi.spyOn(runtime.thread.resume, "resumeThread").mockResolvedValue(undefined); - graph.shell.parts.toolbar.actions.status.connect(); + runtime.shell.parts.toolbar.actions.status.connect(); await waitForAsyncWork(() => { expect(resumeThread).toHaveBeenCalledWith("thread-1"); @@ -234,19 +234,19 @@ describe("createChatPanelSessionGraph actions", () => { expect(ensureConnected).toHaveBeenCalledOnce(); }); - it("disposes presenter and composer resources from the graph action", () => { + it("disposes presenter and composer resources through the complete runtime lifecycle", async () => { const disposePresenter = vi.spyOn(ThreadStreamPresenter.prototype, "dispose").mockImplementation(() => undefined); const disposeComposer = vi.spyOn(ChatComposerController.prototype, "dispose").mockImplementation(() => undefined); - const { graph } = sessionGraphFixture(); + const { runtime } = sessionRuntimeFixture(); - graph.actions.dispose(); + await runtime.dispose(() => undefined); expect(disposePresenter).toHaveBeenCalledOnce(); expect(disposeComposer).toHaveBeenCalledOnce(); }); - function sessionGraphFixture(options: { environment?: PartialChatPanelEnvironment } = {}): { - graph: ReturnType; + function sessionRuntimeFixture(options: { environment?: PartialChatPanelEnvironment } = {}): { + runtime: ChatPanelSessionRuntime; stateStore: ChatStateStore; resumeWork: ChatResumeWorkTracker; deferredTasks: ReturnType; @@ -255,7 +255,7 @@ describe("createChatPanelSessionGraph actions", () => { const resumeWork = new ChatResumeWorkTracker(); const deferredTasks = createChatViewDeferredTasks(() => window); const environment = chatPanelEnvironmentFixture(options.environment); - const graph = createChatPanelSessionGraph({ + const runtime = new ChatPanelSessionRuntime({ environment, stateStore, deferredTasks, @@ -264,7 +264,7 @@ describe("createChatPanelSessionGraph actions", () => { getClosing: () => false, viewWindow: () => window, }); - return { graph, stateStore, resumeWork, deferredTasks }; + return { runtime, stateStore, resumeWork, deferredTasks }; } interface PartialChatPanelEnvironment {