From a97f80e25e3e3cc4802b008ac4fab9b55ae20bf3 Mon Sep 17 00:00:00 2001 From: murashit Date: Thu, 16 Jul 2026 21:44:32 +0900 Subject: [PATCH] refactor(chat): replace signal mirror with selectors --- biome.jsonc | 26 -- docs/design.md | 4 +- package-lock.json | 27 -- package.json | 1 - .../no-chat-shell-read-model-imports.grit | 17 - .../no-chat-signal-type-references.grit | 8 - .../no-preact-signal-imports.grit | 13 - .../chat/host/bundles/composer-bundle.ts | 2 +- .../chat/panel/chat-state-selector.ts | 75 ++++ .../chat/panel/composer-controller.ts | 20 +- src/features/chat/panel/shell-read-model.ts | 414 ------------------ src/features/chat/panel/shell-selectors.ts | 151 +++++++ src/features/chat/panel/shell.dom.tsx | 84 ++-- .../panel/surface/composer-projection.tsx | 43 +- .../chat/panel/surface/goal-projection.tsx | 18 +- .../panel/surface/thread-stream-presenter.ts | 8 +- .../panel/surface/thread-stream-projection.ts | 86 +++- .../chat/panel/surface/toolbar-projection.tsx | 63 ++- .../chat/panel/chat-state-selector.test.tsx | 196 +++++++++ .../chat/panel/composer-controller.test.ts | 23 +- tests/features/chat/panel/shell.test.tsx | 20 +- .../chat/panel/surface/projections.test.ts | 97 +++- .../surface/thread-stream-presenter.test.ts | 46 +- .../features/chat/support/shell-read-model.ts | 10 - .../features/chat/support/shell-selectors.ts | 10 + tests/scripts/grit-policy.test.mjs | 18 - 26 files changed, 778 insertions(+), 702 deletions(-) delete mode 100644 scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit delete mode 100644 scripts/grit/import-boundaries/no-chat-signal-type-references.grit delete mode 100644 scripts/grit/import-boundaries/no-preact-signal-imports.grit create mode 100644 src/features/chat/panel/chat-state-selector.ts delete mode 100644 src/features/chat/panel/shell-read-model.ts create mode 100644 src/features/chat/panel/shell-selectors.ts create mode 100644 tests/features/chat/panel/chat-state-selector.test.tsx delete mode 100644 tests/features/chat/support/shell-read-model.ts create mode 100644 tests/features/chat/support/shell-selectors.ts diff --git a/biome.jsonc b/biome.jsonc index 2cbf643d..3c2ec06c 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -131,32 +131,6 @@ }, // Chat state and runtime ownership. - { - "path": "./scripts/grit/import-boundaries/no-preact-signal-imports.grit", - "includes": [ - "**/src/**/*.{ts,tsx}", - "!**/src/features/chat/panel/shell-read-model.ts", - "!**/src/features/chat/panel/surface/**/*.{ts,tsx}" - ] - }, - { - "path": "./scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit", - "includes": [ - "**/src/features/chat/**/*.{ts,tsx}", - "!**/src/features/chat/panel/shell-read-model.ts", - "!**/src/features/chat/panel/shell.dom.tsx", - "!**/src/features/chat/panel/composer-controller.ts", - "!**/src/features/chat/panel/surface/**/*.{ts,tsx}" - ] - }, - { - "path": "./scripts/grit/import-boundaries/no-chat-signal-type-references.grit", - "includes": [ - "**/src/features/chat/**/*.{ts,tsx}", - "!**/src/features/chat/panel/shell-read-model.ts", - "!**/src/features/chat/panel/surface/**/*.{ts,tsx}" - ] - }, { "path": "./scripts/grit/runtime/no-state-module-side-effects.grit", "includes": ["**/src/features/chat/application/state/**/*.ts"] diff --git a/docs/design.md b/docs/design.md index 31dfe421..d36707b0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -44,11 +44,11 @@ Runtime UI composition is Preact-owned. Preact components should render the pane Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules. -Chat-visible state belongs in the chat state store and named reducer actions. Signals and components may project that state, but they should not become parallel sources of truth. +Chat-visible state belongs in the chat state store and named reducer actions. Components should project that state through narrow selector-backed snapshots rather than mirror it into another reactive store. Each shared app-server resource should have one authoritative query record. Derived metadata may report status but must not duplicate its snapshot. -Preact Signals are a chat panel rendering adapter, not a second state system. Panel surfaces may read narrow signal-backed read models, but application workflows, domain code, presentation helpers, and pure UI components must not depend on broad reducer slices or reactive state primitives. +Each top-level panel region should subscribe to a narrow selector-backed store projection. Region selectors should retain stable snapshots across unrelated state changes so streaming updates do not rerender the toolbar, goal, or composer unnecessarily. Imperative DOM bridges are allowed when an external API, host lifecycle, hit-test, focus/selection operation, or measurement problem requires an `HTMLElement`. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces. diff --git a/package-lock.json b/package-lock.json index 61a44b7b..2a3fa630 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "5.0.1", "license": "Apache-2.0", "dependencies": { - "@preact/signals": "^2.9.3", "@tanstack/query-core": "^5.101.2", "defuddle": "^0.19.1", "micromark": "^4.0.2", @@ -2358,32 +2357,6 @@ "url": "https://opencollective.com/unts" } }, - "node_modules/@preact/signals": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.9.3.tgz", - "integrity": "sha512-4g3L/dYgUt0PPE6mnYsgCP/3DEhmAYTQxjzwfSPnXQ7Lj1WPBBEY83F48X0RcNEP3ycr57T8Hcpg4PVU5VM7pg==", - "license": "MIT", - "dependencies": { - "@preact/signals-core": "^1.14.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact": ">= 10.25.0 || >=11.0.0-0" - } - }, - "node_modules/@preact/signals-core": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", - "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", diff --git a/package.json b/package.json index 36f473ef..cb30f293 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "vitest": "^4.1.10" }, "dependencies": { - "@preact/signals": "^2.9.3", "@tanstack/query-core": "^5.101.2", "defuddle": "^0.19.1", "micromark": "^4.0.2", diff --git a/scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit b/scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit deleted file mode 100644 index ef45591f..00000000 --- a/scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit +++ /dev/null @@ -1,17 +0,0 @@ -language js - -private pattern js_module_reference() { - or { - JsImport(), - JsImportCallExpression() - } -} - -private pattern chat_shell_read_model_source() { - r"^[\"'](?:(?:\.\./)+(?:panel/)?shell-read-model|(?:\./)?shell-read-model|src/features/chat/panel/shell-read-model)(?:\.ts)?[\"']$" -} - -js_module_reference() as $stmt where { - $stmt <: contains `$source` where { $source <: chat_shell_read_model_source() }, - register_diagnostic(span=$stmt, message="Import chat panel signal read models only from the shell and panel surface rendering adapters.", severity="error") -} diff --git a/scripts/grit/import-boundaries/no-chat-signal-type-references.grit b/scripts/grit/import-boundaries/no-chat-signal-type-references.grit deleted file mode 100644 index 588a8ec1..00000000 --- a/scripts/grit/import-boundaries/no-chat-signal-type-references.grit +++ /dev/null @@ -1,8 +0,0 @@ -language js - -or { - `ReadonlySignal<$type>` as $stmt, - `Signal<$type>` as $stmt -} where { - register_diagnostic(span=$stmt, message="Keep Preact signal types inside the chat panel read-model and surface rendering adapter layer.", severity="error") -} diff --git a/scripts/grit/import-boundaries/no-preact-signal-imports.grit b/scripts/grit/import-boundaries/no-preact-signal-imports.grit deleted file mode 100644 index 7eb6386d..00000000 --- a/scripts/grit/import-boundaries/no-preact-signal-imports.grit +++ /dev/null @@ -1,13 +0,0 @@ -language js - -private pattern js_module_reference() { - or { - JsImport(), - JsImportCallExpression() - } -} - -js_module_reference() as $stmt where { - $stmt <: contains `$source` where { $source <: r"^[\"']@preact/signals[\"']$" }, - register_diagnostic(span=$stmt, message="Do not import @preact/signals from this module.", severity="error") -} diff --git a/src/features/chat/host/bundles/composer-bundle.ts b/src/features/chat/host/bundles/composer-bundle.ts index 49b7be79..d97e1e77 100644 --- a/src/features/chat/host/bundles/composer-bundle.ts +++ b/src/features/chat/host/bundles/composer-bundle.ts @@ -40,7 +40,7 @@ export function createChatComposerController( sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(), scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges(), canInterrupt: (model) => { - return model.turnBusy.value && Boolean(model.activeThreadId.value && model.activeTurnId.value); + return model.turnBusy && Boolean(model.activeThreadId && model.activeTurnId); }, composerProjection: (model) => chatPanelComposerProjection(model, { diff --git a/src/features/chat/panel/chat-state-selector.ts b/src/features/chat/panel/chat-state-selector.ts new file mode 100644 index 00000000..1b26bacb --- /dev/null +++ b/src/features/chat/panel/chat-state-selector.ts @@ -0,0 +1,75 @@ +import { useLayoutEffect, useReducer, useRef } from "preact/hooks"; +import type { ChatState } from "../application/state/root-reducer"; +import type { ChatStateStore } from "../application/state/store"; + +type ChatSelector = (state: ChatState) => Selection; +type SelectionEquality = (left: Selection, right: Selection) => boolean; + +interface SelectionCache { + store: ChatStateStore; + selector: ChatSelector; + state: ChatState; + selection: Selection; +} + +export function useChatSelector( + store: ChatStateStore, + selector: ChatSelector, + equality: SelectionEquality = shallowEqual, +): Selection { + const cacheRef = useRef | null>(null); + const [, rerender] = useReducer((version: number) => version + 1, 0); + const state = store.getState(); + const previous = cacheRef.current; + const selection = selectionFor(previous, store, selector, state, equality); + cacheRef.current = { store, selector, state, selection }; + + useLayoutEffect(() => { + let active = true; + const update = (): void => { + if (!active) return; + const current = cacheRef.current; + if (!current || current.store !== store || current.selector !== selector) return; + const nextState = store.getState(); + if (nextState === current.state) return; + const nextSelection = selector(nextState); + if (equality(current.selection, nextSelection)) { + cacheRef.current = { store, selector, state: nextState, selection: current.selection }; + return; + } + cacheRef.current = { store, selector, state: nextState, selection: nextSelection }; + rerender(undefined); + }; + + const unsubscribe = store.subscribe(update); + update(); + return () => { + active = false; + unsubscribe(); + }; + }, [store, selector, equality]); + + return selection; +} + +function selectionFor( + previous: SelectionCache | null, + store: ChatStateStore, + selector: ChatSelector, + state: ChatState, + equality: SelectionEquality, +): Selection { + if (previous?.store === store && previous.selector === selector) { + if (previous.state === state) return previous.selection; + const next = selector(state); + return equality(previous.selection, next) ? previous.selection : next; + } + return selector(state); +} + +function shallowEqual(left: object, right: object): boolean { + if (left === right) return true; + const leftKeys = Object.keys(left); + if (leftKeys.length !== Object.keys(right).length) return false; + return leftKeys.every((key) => Object.is(left[key as keyof typeof left], right[key as keyof typeof right])); +} diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index 9405f4aa..7423a5d0 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -43,7 +43,7 @@ import { composerTransferHasFiles, focusComposer, } from "./composer-element.dom"; -import type { ChatPanelComposerReadModel } from "./shell-read-model"; +import type { ChatPanelComposerModel } from "./shell-selectors"; import type { ChatPanelComposerProjection } from "./surface/composer-projection"; export interface ChatComposerControllerOptions { @@ -56,8 +56,8 @@ export interface ChatComposerControllerOptions { referenceActiveNoteOnSend: () => boolean; sendShortcut: () => SendShortcut; scrollThreadFromComposerEdges: () => boolean; - canInterrupt: (model: ChatPanelComposerReadModel) => boolean; - composerProjection: (model: ChatPanelComposerReadModel) => ChatPanelComposerProjection; + canInterrupt: (model: ChatPanelComposerModel) => boolean; + composerProjection: (model: ChatPanelComposerModel) => ChatPanelComposerProjection; currentModelForSuggestions: () => string | null; threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void; togglePlan: () => void; @@ -100,18 +100,18 @@ export class ChatComposerController { return this.draft.trim(); } - renderState(model: ChatPanelComposerReadModel, actions: ChatComposerRenderActions): ComposerShellProps { + renderState(model: ChatPanelComposerModel, actions: ChatComposerRenderActions): ComposerShellProps { const projection = this.options.composerProjection(model); return { viewId: this.options.viewId, - draft: model.draft.value, - busy: model.turnBusy.value, + draft: model.draft, + busy: model.turnBusy, canInterrupt: this.options.canInterrupt(model), - submissionDisabled: model.activeThreadSubagent.value || model.webSubmissionPending.value, - webSubmissionCancellable: model.webSubmissionCancellable.value, + submissionDisabled: model.activeThreadSubagent || model.webSubmissionPending, + webSubmissionCancellable: model.webSubmissionCancellable, normalPlaceholder: projection.placeholder, - suggestions: model.suggestions.value, - selectedSuggestionIndex: model.selectedSuggestionIndex.value, + suggestions: model.suggestions, + selectedSuggestionIndex: model.selectedSuggestionIndex, pendingSelection: this.pendingSelection, onPendingSelectionApplied: this.clearPendingSelection, callbacks: this.composerCallbacks(actions), diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts deleted file mode 100644 index 77be4814..00000000 --- a/src/features/chat/panel/shell-read-model.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { batch, computed, type ReadonlySignal, type Signal, signal } from "@preact/signals"; -import { explicitThreadName } from "../../../domain/threads/model"; -import { runtimeSnapshotForChatSlices, threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot"; -import type { ChatActiveThreadState, ChatState } from "../application/state/root-reducer"; -import { - type ThreadStreamRollbackCandidate, - threadStreamActiveItems, - threadStreamItems, - threadStreamRollbackCandidateFromItems, - threadStreamStableItems, -} from "../application/state/thread-stream"; -import { implementPlanTarget } from "../application/turns/plan-implementation"; -import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state"; -import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; -import type { ThreadStreamItem } from "../domain/thread-stream/items"; -import { type ForkCandidate, forkCandidatesFromItems, type PlanImplementationTarget } from "../domain/thread-stream/selectors"; - -export interface ChatPanelShellReadModelBinding { - readonly readModel: ChatPanelShellReadModel; - sync(nextState: ChatState): void; -} - -interface ChatPanelShellReadModel { - readonly toolbar: ChatPanelToolbarReadModel; - readonly goal: ChatPanelGoalReadModel; - readonly threadStream: ChatPanelThreadStreamReadModel; - readonly composer: ChatPanelComposerReadModel; -} - -interface ChatPanelShellSignals { - connection: Signal; - threadList: Signal; - panelThread: Signal; - activeThread: ReadonlySignal; - runtime: Signal; - turn: Signal; - threadStream: Signal; - pendingSubmission: Signal; - requests: Signal; - composer: Signal; - ui: Signal; - turnBusy: ReadonlySignal; - activeTurnId: ReadonlySignal; - activeThreadId: ReadonlySignal; - activeThreadCwd: ReadonlySignal; - activeThreadGoal: ReadonlySignal; - threadStreamItems: ReadonlySignal; - threadStreamStableItems: ReadonlySignal; - threadStreamActiveItems: ReadonlySignal; - threadStreamRollbackCandidate: ReadonlySignal; - threadStreamForkCandidates: ReadonlySignal; - threadStreamImplementPlanTarget: ReadonlySignal; - webSubmissionPending: ReadonlySignal; - webSubmissionCancellable: ReadonlySignal; - threadStreamDisclosures: ReadonlySignal; - threadStreamForkMenuItemId: ReadonlySignal; - hasThreadTurns: ReadonlySignal; - goalEditor: ReadonlySignal; - goalObjectiveExpanded: ReadonlySignal; - toolbarRuntimeSnapshot: ReadonlySignal; - composerRuntimeSnapshot: ReadonlySignal; -} - -// Toolbar read model - -type ChatPanelToolbarDebugConnectionState = Pick< - ChatState["connection"], - "phase" | "statusText" | "initializeResponse" | "rateLimit" | "serverDiagnostics" ->; - -interface ChatPanelToolbarDiagnosticState { - readonly initializeResponse: ChatState["connection"]["initializeResponse"]; - readonly serverDiagnostics: ChatState["connection"]["serverDiagnostics"]; -} - -interface ChatPanelToolbarDebugState { - readonly activeThreadId: string | null; - readonly connection: ChatPanelToolbarDebugConnectionState; - readonly runtimeConfig: ChatState["connection"]["runtimeConfig"]; - readonly runtime: ChatState["runtime"]; - readonly availableModels: ChatState["connection"]["availableModels"]; -} - -export interface ChatPanelToolbarReadModel { - readonly threads: ReadonlySignal; - readonly activeThreadId: ReadonlySignal; - readonly activeThreadSubagent: ReadonlySignal; - readonly turnBusy: ReadonlySignal; - readonly runtimeSnapshot: ReadonlySignal; - readonly toolbarPanel: ReadonlySignal; - readonly archiveConfirmThreadId: ReadonlySignal; - readonly rename: ReadonlySignal; - readonly diagnostics: ReadonlySignal; - readonly debug: ReadonlySignal; -} - -// Goal read model - -export interface ChatPanelGoalReadModel { - readonly goal: ReadonlySignal; - readonly goalEditor: ReadonlySignal; - readonly goalObjectiveExpanded: ReadonlySignal; -} - -// Thread stream read model - -export interface ChatPanelThreadStreamReadModel { - readonly activeThreadId: ReadonlySignal; - readonly activeThreadCwd: ReadonlySignal; - readonly activeTurnId: ReadonlySignal; - readonly historyCursor: ReadonlySignal; - readonly loadingHistory: ReadonlySignal; - readonly turnDiffs: ReadonlySignal; - readonly items: ReadonlySignal; - readonly stableItems: ReadonlySignal; - readonly activeItems: ReadonlySignal; - readonly requests: ReadonlySignal; - readonly disclosures: ReadonlySignal; - readonly forkMenuItemId: ReadonlySignal; - readonly rollbackCandidate: ReadonlySignal; - readonly forkCandidates: ReadonlySignal; - readonly implementPlanTarget: ReadonlySignal; -} - -type ChatPanelThreadStreamDisclosureBucket = Exclude; - -type ChatPanelThreadStreamDisclosureState = Pick; - -// Composer read model - -type ChatPanelComposerConnectionState = Pick; - -export interface ChatPanelComposerReadModel { - readonly connection: { - readonly phase: ReadonlySignal; - readonly runtimeConfig: ReadonlySignal; - readonly availableModels: ReadonlySignal; - }; - readonly activeListedThreadName: ReadonlySignal; - readonly sideChatActive: ReadonlySignal; - readonly sideChatSourceTitle: ReadonlySignal; - readonly draft: ReadonlySignal; - readonly suggestions: ReadonlySignal; - readonly selectedSuggestionIndex: ReadonlySignal; - readonly activeThreadId: ReadonlySignal; - readonly activeThreadSubagent: ReadonlySignal; - readonly webSubmissionPending: ReadonlySignal; - readonly webSubmissionCancellable: ReadonlySignal; - readonly turnBusy: ReadonlySignal; - readonly activeTurnId: ReadonlySignal; - readonly runtimeSnapshot: ReadonlySignal; -} - -export function createChatPanelShellReadModelBinding(initialState: ChatState): ChatPanelShellReadModelBinding { - const connection = signal(initialState.connection); - const threadList = signal(initialState.threadList); - const panelThread = signal(initialState.panelThread); - const activeThread = computed(() => (panelThread.value.kind === "active" ? panelThread.value.thread : null)); - const runtime = signal(initialState.runtime); - const turn = signal(initialState.turn); - const threadStream = signal(initialState.threadStream); - const pendingSubmission = signal(initialState.pendingSubmission); - const requests = signal(initialState.requests); - const composer = signal(initialState.composer); - const ui = signal(initialState.ui); - const turnBusy = computed(() => chatTurnBusy({ turn: turn.value })); - const canonicalStreamItems = computed(() => threadStreamItems(threadStream.value)); - const streamItems = computed(() => appendPendingSubmission(canonicalStreamItems.value, pendingSubmission.value)); - const hasThreadTurns = computed(() => threadStreamItemsHaveThreadTurns(canonicalStreamItems.value)); - const activeThreadIdSignal = computed(() => activeThread.value?.id ?? null); - const activeThreadCwd = computed(() => activeThread.value?.cwd ?? null); - const activeThreadTokenUsage = computed(() => activeThread.value?.tokenUsage ?? null); - const activeThreadGoal = computed(() => activeThread.value?.goal ?? null); - const signals: ChatPanelShellSignals = { - connection, - threadList, - panelThread, - activeThread, - runtime, - turn, - threadStream, - pendingSubmission, - requests, - composer, - ui, - turnBusy, - activeTurnId: computed(() => activeTurnId({ turn: turn.value })), - activeThreadId: activeThreadIdSignal, - activeThreadCwd, - activeThreadGoal, - threadStreamItems: streamItems, - threadStreamStableItems: computed(() => - threadStream.value.activeSegment - ? threadStreamStableItems(threadStream.value) - : appendPendingSubmission(threadStreamStableItems(threadStream.value), pendingSubmission.value), - ), - threadStreamActiveItems: computed(() => - threadStream.value.activeSegment - ? appendPendingSubmission(threadStreamActiveItems(threadStream.value), pendingSubmission.value) - : threadStreamActiveItems(threadStream.value), - ), - threadStreamRollbackCandidate: computed(() => - turnBusy.value || activeThread.value?.lifetime?.kind === "ephemeral" || activeThread.value?.provenance?.kind === "subagent" - ? null - : threadStreamRollbackCandidateFromItems(canonicalStreamItems.value), - ), - threadStreamForkCandidates: computed(() => - turnBusy.value || activeThread.value?.lifetime?.kind === "ephemeral" || activeThread.value?.provenance?.kind === "subagent" - ? [] - : forkCandidatesFromItems(canonicalStreamItems.value), - ), - threadStreamImplementPlanTarget: computed(() => - implementPlanTarget({ - activeThread: activeThread.value, - turn: turn.value, - runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } }, - threadStream: threadStream.value, - }), - ), - webSubmissionPending: computed(() => pendingSubmission.value !== null), - webSubmissionCancellable: computed(() => pendingSubmission.value?.phase === "cancellable"), - threadStreamDisclosures: createThreadStreamDisclosuresSignal(ui), - threadStreamForkMenuItemId: computed(() => ui.value.threadStreamActionMenu.forkMenuItemId), - hasThreadTurns, - goalEditor: computed(() => ui.value.goalEditor), - goalObjectiveExpanded: computed(() => ui.value.disclosures.goalObjectiveExpanded), - toolbarRuntimeSnapshot: computed(() => - runtimeSnapshotForChatSlices({ - runtimeConfig: connection.value.runtimeConfig, - activeThread: { id: activeThreadIdSignal.value, tokenUsage: activeThreadTokenUsage.value }, - runtime: runtime.value, - rateLimit: connection.value.rateLimit, - hasThreadTurns: false, - availableModels: connection.value.availableModels, - }), - ), - composerRuntimeSnapshot: computed(() => - runtimeSnapshotForChatSlices({ - runtimeConfig: connection.value.runtimeConfig, - activeThread: { id: activeThreadIdSignal.value, tokenUsage: activeThreadTokenUsage.value }, - runtime: runtime.value, - rateLimit: connection.value.rateLimit, - hasThreadTurns: hasThreadTurns.value, - availableModels: connection.value.availableModels, - }), - ), - }; - const readModel = shellReadModelFromSignals(signals); - return { - readModel, - sync: (nextState) => { - syncShellSignals(signals, nextState); - }, - }; -} - -function syncShellSignals(signals: ChatPanelShellSignals, nextState: ChatState): void { - batch(() => { - if (signals.connection.value !== nextState.connection) signals.connection.value = nextState.connection; - if (signals.threadList.value !== nextState.threadList) signals.threadList.value = nextState.threadList; - if (signals.panelThread.value !== nextState.panelThread) signals.panelThread.value = nextState.panelThread; - if (signals.runtime.value !== nextState.runtime) signals.runtime.value = nextState.runtime; - if (signals.turn.value !== nextState.turn) signals.turn.value = nextState.turn; - if (signals.threadStream.value !== nextState.threadStream) signals.threadStream.value = nextState.threadStream; - if (signals.pendingSubmission.value !== nextState.pendingSubmission) signals.pendingSubmission.value = nextState.pendingSubmission; - if (signals.requests.value !== nextState.requests) signals.requests.value = nextState.requests; - if (signals.composer.value !== nextState.composer) signals.composer.value = nextState.composer; - if (signals.ui.value !== nextState.ui) signals.ui.value = nextState.ui; - }); -} - -function appendPendingSubmission( - items: readonly ThreadStreamItem[], - pendingSubmission: ChatState["pendingSubmission"], -): readonly ThreadStreamItem[] { - return pendingSubmission ? [...items, pendingSubmission.item] : items; -} - -function shellReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelShellReadModel { - return { - toolbar: toolbarReadModelFromSignals(signals), - goal: goalReadModelFromSignals(signals), - threadStream: threadStreamReadModelFromSignals(signals), - composer: composerReadModelFromSignals(signals), - }; -} - -function toolbarReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelToolbarReadModel { - return { - threads: computed(() => signals.threadList.value.listedThreads), - activeThreadId: signals.activeThreadId, - activeThreadSubagent: computed(() => signals.activeThread.value?.provenance?.kind === "subagent"), - turnBusy: signals.turnBusy, - runtimeSnapshot: signals.toolbarRuntimeSnapshot, - toolbarPanel: computed(() => signals.ui.value.toolbarPanel), - archiveConfirmThreadId: computed(() => signals.ui.value.archiveConfirmThreadId), - rename: computed(() => signals.ui.value.rename), - diagnostics: computed(() => toolbarDiagnosticState(signals.connection.value)), - debug: computed(() => toolbarDebugState(signals, signals.connection.value)), - }; -} - -function toolbarDiagnosticState(connection: ChatState["connection"]): ChatPanelToolbarDiagnosticState { - return { - initializeResponse: connection.initializeResponse, - serverDiagnostics: connection.serverDiagnostics, - }; -} - -function toolbarDebugState(signals: ChatPanelShellSignals, connection: ChatState["connection"]): ChatPanelToolbarDebugState { - return { - activeThreadId: signals.activeThreadId.value, - connection: { - phase: connection.phase, - statusText: connection.statusText, - initializeResponse: connection.initializeResponse, - rateLimit: connection.rateLimit, - serverDiagnostics: connection.serverDiagnostics, - }, - runtimeConfig: connection.runtimeConfig, - runtime: signals.runtime.value, - availableModels: connection.availableModels, - }; -} - -function goalReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelGoalReadModel { - return { - goal: signals.activeThreadGoal, - goalEditor: signals.goalEditor, - goalObjectiveExpanded: signals.goalObjectiveExpanded, - }; -} - -function threadStreamReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelThreadStreamReadModel { - return { - activeThreadId: signals.activeThreadId, - activeThreadCwd: signals.activeThreadCwd, - activeTurnId: signals.activeTurnId, - historyCursor: computed(() => signals.threadStream.value.historyCursor), - loadingHistory: computed(() => signals.threadStream.value.loadingHistory), - turnDiffs: computed(() => signals.threadStream.value.turnDiffs), - items: signals.threadStreamItems, - stableItems: signals.threadStreamStableItems, - activeItems: signals.threadStreamActiveItems, - requests: signals.requests, - disclosures: signals.threadStreamDisclosures, - forkMenuItemId: signals.threadStreamForkMenuItemId, - rollbackCandidate: signals.threadStreamRollbackCandidate, - forkCandidates: signals.threadStreamForkCandidates, - implementPlanTarget: signals.threadStreamImplementPlanTarget, - }; -} - -function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelComposerReadModel { - return { - connection: { - phase: computed(() => signals.connection.value.phase), - runtimeConfig: computed(() => signals.connection.value.runtimeConfig), - availableModels: computed(() => signals.connection.value.availableModels), - }, - activeListedThreadName: computed(() => activeListedThreadName(signals)), - sideChatActive: computed(() => signals.activeThread.value?.lifetime?.kind === "ephemeral"), - sideChatSourceTitle: computed(() => { - const lifetime = signals.activeThread.value?.lifetime; - return lifetime?.kind === "ephemeral" ? lifetime.sourceThreadTitle : null; - }), - draft: computed(() => signals.composer.value.draft), - suggestions: computed(() => signals.composer.value.suggestions), - selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected), - activeThreadId: signals.activeThreadId, - activeThreadSubagent: computed(() => signals.activeThread.value?.provenance?.kind === "subagent"), - webSubmissionPending: signals.webSubmissionPending, - webSubmissionCancellable: signals.webSubmissionCancellable, - turnBusy: signals.turnBusy, - activeTurnId: signals.activeTurnId, - runtimeSnapshot: signals.composerRuntimeSnapshot, - }; -} - -function activeListedThreadName(signals: ChatPanelShellSignals): string | null { - const threadId = signals.activeThreadId.value; - if (!threadId) return null; - return projectedThreadName(signals, threadId); -} - -function projectedThreadName(signals: ChatPanelShellSignals, threadId: string): string | null { - const thread = signals.threadList.value.listedThreads.find((item) => item.id === threadId); - return thread ? explicitThreadName(thread) : null; -} - -function createThreadStreamDisclosuresSignal(ui: Signal): ReadonlySignal { - let previous: ChatPanelThreadStreamDisclosureState | null = null; - return computed(() => { - const disclosures = ui.value.disclosures; - if ( - previous && - previous.details === disclosures.details && - previous.activityGroups === disclosures.activityGroups && - previous.textDetails === disclosures.textDetails && - previous.userDialogueExpanded === disclosures.userDialogueExpanded && - previous.approvalDetails === disclosures.approvalDetails - ) { - return previous; - } - previous = { - details: disclosures.details, - activityGroups: disclosures.activityGroups, - textDetails: disclosures.textDetails, - userDialogueExpanded: disclosures.userDialogueExpanded, - approvalDetails: disclosures.approvalDetails, - }; - return previous; - }); -} diff --git a/src/features/chat/panel/shell-selectors.ts b/src/features/chat/panel/shell-selectors.ts new file mode 100644 index 00000000..ccc32c00 --- /dev/null +++ b/src/features/chat/panel/shell-selectors.ts @@ -0,0 +1,151 @@ +import { explicitThreadName } from "../../../domain/threads/model"; +import { threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot"; +import { activeThreadState, type ChatActiveThreadState, type ChatState } from "../application/state/root-reducer"; +import { threadStreamItems } from "../application/state/thread-stream"; +import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state"; + +const hasThreadTurnsByStream = new WeakMap(); + +export interface ChatPanelToolbarModel { + readonly threads: ChatState["threadList"]["listedThreads"]; + readonly activeThreadId: string | null; + readonly activeThreadSubagent: boolean; + readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"]; + readonly turnBusy: boolean; + readonly connection: ChatState["connection"]; + readonly runtime: ChatState["runtime"]; + readonly toolbarPanel: ChatState["ui"]["toolbarPanel"]; + readonly archiveConfirmThreadId: ChatState["ui"]["archiveConfirmThreadId"]; + readonly rename: ChatState["ui"]["rename"]; +} + +export interface ChatPanelGoalModel { + readonly goal: ChatActiveThreadState["goal"]; + readonly goalEditor: ChatState["ui"]["goalEditor"]; + readonly goalObjectiveExpanded: ChatState["ui"]["disclosures"]["goalObjectiveExpanded"]; +} + +export interface ChatPanelThreadStreamModel { + readonly activeThreadId: string | null; + readonly activeThreadCwd: ChatActiveThreadState["cwd"]; + readonly activeThreadLifetime: ChatActiveThreadState["lifetime"]; + readonly activeThreadProvenance: ChatActiveThreadState["provenance"]; + readonly turn: ChatState["turn"]; + readonly runtimeCollaborationMode: ChatState["runtime"]["pending"]["collaborationMode"]; + readonly threadStream: ChatState["threadStream"]; + readonly pendingSubmission: ChatState["pendingSubmission"]; + readonly requests: ChatState["requests"]; + readonly disclosureDetails: ChatState["ui"]["disclosures"]["details"]; + readonly disclosureActivityGroups: ChatState["ui"]["disclosures"]["activityGroups"]; + readonly disclosureTextDetails: ChatState["ui"]["disclosures"]["textDetails"]; + readonly disclosureUserDialogueExpanded: ChatState["ui"]["disclosures"]["userDialogueExpanded"]; + readonly disclosureApprovalDetails: ChatState["ui"]["disclosures"]["approvalDetails"]; + readonly forkMenuItemId: ChatState["ui"]["threadStreamActionMenu"]["forkMenuItemId"]; +} + +export interface ChatPanelComposerModel { + readonly connectionPhase: ChatState["connection"]["phase"]; + readonly runtimeConfig: ChatState["connection"]["runtimeConfig"]; + readonly availableModels: ChatState["connection"]["availableModels"]; + readonly rateLimit: ChatState["connection"]["rateLimit"]; + readonly activeListedThreadName: string | null; + readonly sideChatActive: boolean; + readonly sideChatSourceTitle: string | null; + readonly draft: ChatState["composer"]["draft"]; + readonly suggestions: ChatState["composer"]["suggestions"]; + readonly selectedSuggestionIndex: ChatState["composer"]["suggestSelected"]; + readonly activeThreadId: string | null; + readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"]; + readonly activeThreadSubagent: boolean; + readonly webSubmissionPending: boolean; + readonly webSubmissionCancellable: boolean; + readonly turnBusy: boolean; + readonly activeTurnId: string | null; + readonly runtime: ChatState["runtime"]; + readonly hasThreadTurns: boolean; +} + +export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel { + const activeThread = activeThreadState(state); + return { + threads: state.threadList.listedThreads, + activeThreadId: activeThread?.id ?? null, + activeThreadSubagent: activeThread?.provenance?.kind === "subagent", + activeThreadTokenUsage: activeThread?.tokenUsage ?? null, + turnBusy: chatTurnBusy(state), + connection: state.connection, + runtime: state.runtime, + toolbarPanel: state.ui.toolbarPanel, + archiveConfirmThreadId: state.ui.archiveConfirmThreadId, + rename: state.ui.rename, + }; +} + +export function selectChatPanelGoal(state: ChatState): ChatPanelGoalModel { + return { + goal: activeThreadState(state)?.goal ?? null, + goalEditor: state.ui.goalEditor, + goalObjectiveExpanded: state.ui.disclosures.goalObjectiveExpanded, + }; +} + +export function selectChatPanelThreadStream(state: ChatState): ChatPanelThreadStreamModel { + const activeThread = activeThreadState(state); + return { + activeThreadId: activeThread?.id ?? null, + activeThreadCwd: activeThread?.cwd ?? null, + activeThreadLifetime: activeThread?.lifetime ?? null, + activeThreadProvenance: activeThread?.provenance ?? null, + turn: state.turn, + runtimeCollaborationMode: state.runtime.pending.collaborationMode, + threadStream: state.threadStream, + pendingSubmission: state.pendingSubmission, + requests: state.requests, + disclosureDetails: state.ui.disclosures.details, + disclosureActivityGroups: state.ui.disclosures.activityGroups, + disclosureTextDetails: state.ui.disclosures.textDetails, + disclosureUserDialogueExpanded: state.ui.disclosures.userDialogueExpanded, + disclosureApprovalDetails: state.ui.disclosures.approvalDetails, + forkMenuItemId: state.ui.threadStreamActionMenu.forkMenuItemId, + }; +} + +export function selectChatPanelComposer(state: ChatState): ChatPanelComposerModel { + const activeThread = activeThreadState(state); + const activeThreadId = activeThread?.id ?? null; + const lifetime = activeThread?.lifetime; + return { + connectionPhase: state.connection.phase, + runtimeConfig: state.connection.runtimeConfig, + availableModels: state.connection.availableModels, + rateLimit: state.connection.rateLimit, + activeListedThreadName: activeThreadId ? projectedThreadName(state, activeThreadId) : null, + sideChatActive: lifetime?.kind === "ephemeral", + sideChatSourceTitle: lifetime?.kind === "ephemeral" ? lifetime.sourceThreadTitle : null, + draft: state.composer.draft, + suggestions: state.composer.suggestions, + selectedSuggestionIndex: state.composer.suggestSelected, + activeThreadId, + activeThreadTokenUsage: activeThread?.tokenUsage ?? null, + activeThreadSubagent: activeThread?.provenance?.kind === "subagent", + webSubmissionPending: state.pendingSubmission !== null, + webSubmissionCancellable: state.pendingSubmission?.phase === "cancellable", + turnBusy: chatTurnBusy(state), + activeTurnId: activeTurnId(state), + runtime: state.runtime, + hasThreadTurns: hasThreadTurns(state.threadStream), + }; +} + +function hasThreadTurns(threadStream: ChatState["threadStream"]): boolean { + const cached = hasThreadTurnsByStream.get(threadStream); + if (cached !== undefined) return cached; + const result = threadStreamItemsHaveThreadTurns(threadStreamItems(threadStream)); + hasThreadTurnsByStream.set(threadStream, result); + return result; +} + +function projectedThreadName(state: ChatState, threadId: string): string | null { + const thread = state.threadList.listedThreads.find((item) => item.id === threadId); + return thread ? explicitThreadName(thread) : null; +} diff --git a/src/features/chat/panel/shell.dom.tsx b/src/features/chat/panel/shell.dom.tsx index 2a4073d0..bb19cfb7 100644 --- a/src/features/chat/panel/shell.dom.tsx +++ b/src/features/chat/panel/shell.dom.tsx @@ -1,9 +1,11 @@ import type { ComponentChild as UiNode } from "preact"; +import { useMemo } from "preact/hooks"; import { listenDomEvent } from "../../../shared/dom/events.dom"; import { renderUiRoot, unmountUiRoot } from "../../../shared/dom/preact-root.dom"; import type { ChatStateStore } from "../application/state/store"; import type { ToolbarActions } from "../ui/toolbar"; -import { type ChatPanelShellReadModelBinding, createChatPanelShellReadModelBinding } from "./shell-read-model"; +import { useChatSelector } from "./chat-state-selector"; +import { selectChatPanelComposer, selectChatPanelGoal, selectChatPanelThreadStream, selectChatPanelToolbar } from "./shell-selectors"; import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerPresenter } from "./surface/composer-projection"; import { ChatPanelGoal, type ChatPanelGoalSurface } from "./surface/goal-projection"; import { ChatPanelThreadStream, type ChatPanelThreadStreamPresenter } from "./surface/thread-stream-presenter"; @@ -30,10 +32,7 @@ export interface ChatPanelShellProps { interface ChatPanelShellMount { props: ChatPanelShellProps; - stateStore: ChatStateStore; - unsubscribe: () => void; stopStatusBarClearanceSync: () => void; - shellReadModelBinding: ChatPanelShellReadModelBinding; } const shellMounts = new WeakMap(); @@ -41,14 +40,13 @@ const shellMounts = new WeakMap(); export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void { container.addClass("codex-panel"); const existing = shellMounts.get(container); - const mount = existing?.stateStore === props.stateStore ? existing : createShellMount(container, props); + const mount = existing ?? createShellMount(container, props); renderMountedShell(container, mount, props); } export function unmountChatPanelShell(container: HTMLElement | null): void { if (!container) return; const mount = shellMounts.get(container); - mount?.unsubscribe(); mount?.stopStatusBarClearanceSync(); shellMounts.delete(container); unmountUiRoot(container); @@ -57,17 +55,9 @@ export function unmountChatPanelShell(container: HTMLElement | null): void { function createShellMount(container: HTMLElement, props: ChatPanelShellProps): ChatPanelShellMount { const existing = shellMounts.get(container); - existing?.unsubscribe(); existing?.stopStatusBarClearanceSync(); const mount: ChatPanelShellMount = { props, - stateStore: props.stateStore, - shellReadModelBinding: createChatPanelShellReadModelBinding(props.stateStore.getState()), - unsubscribe: props.stateStore.subscribe(() => { - const current = shellMounts.get(container); - if (!current) return; - current.shellReadModelBinding.sync(props.stateStore.getState()); - }), stopStatusBarClearanceSync: startStatusBarClearanceSync(container), }; shellMounts.set(container, mount); @@ -80,7 +70,7 @@ function renderMountedShell(container: HTMLElement, mount: ChatPanelShellMount, container.replaceChildren(); } syncStatusBarClearance(container); - renderUiRoot(container, ); + renderUiRoot(container, ); mount.props = props; } @@ -104,32 +94,72 @@ function shellRegion(container: HTMLElement, region: string): HTMLElement | null return container.querySelector(`:scope > [data-codex-panel-shell-region="${region}"]`); } -function ChatPanelShell({ - showToolbar, - parts, - shellReadModelBinding, -}: ChatPanelShellProps & { shellReadModelBinding: ChatPanelShellReadModelBinding }): UiNode { - const readModel = shellReadModelBinding.readModel; +function ChatPanelShell({ stateStore, showToolbar, parts }: ChatPanelShellProps): UiNode { return ( <> {showToolbar ? ( -
- +
+
) : null} -
+
- +
- +
- +
); } +function ChatPanelToolbarRegion({ + stateStore, + surface, + actions, +}: { + stateStore: ChatStateStore; + surface: ChatPanelToolbarSurface; + actions: ToolbarActions; +}): UiNode { + const model = useChatSelector(stateStore, selectChatPanelToolbar); + return useMemo( + () => , + [model, stateStore, surface, actions], + ); +} + +function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateStore; surface: ChatPanelGoalSurface }): UiNode { + const model = useChatSelector(stateStore, selectChatPanelGoal); + return useMemo(() => , [model, surface]); +} + +function ChatPanelThreadStreamRegion({ + stateStore, + presenter, +}: { + stateStore: ChatStateStore; + presenter: ChatPanelThreadStreamPresenter; +}): UiNode { + const model = useChatSelector(stateStore, selectChatPanelThreadStream); + return useMemo(() => , [model, presenter]); +} + +function ChatPanelComposerRegion({ + stateStore, + presenter, + actions, +}: { + stateStore: ChatStateStore; + presenter: ChatPanelComposerPresenter; + actions: ChatPanelComposerActions; +}): UiNode { + const model = useChatSelector(stateStore, selectChatPanelComposer); + return useMemo(() => , [model, presenter, actions]); +} + function startStatusBarClearanceSync(container: HTMLElement): () => void { const win = container.ownerDocument.defaultView; if (!win) return () => undefined; diff --git a/src/features/chat/panel/surface/composer-projection.tsx b/src/features/chat/panel/surface/composer-projection.tsx index 7a6aa7cb..b14bd872 100644 --- a/src/features/chat/panel/surface/composer-projection.tsx +++ b/src/features/chat/panel/surface/composer-projection.tsx @@ -3,12 +3,13 @@ import { h } from "preact"; import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { sortedModelMetadata } from "../../../../domain/catalog/metadata"; import { runtimeConfigOrDefault } from "../../../../domain/runtime/config"; +import { runtimeSnapshotForChatSlices } from "../../application/runtime/snapshot"; import { compactReasoningEffortLabel } from "../../domain/runtime/labels"; import { resolveRuntimeControls } from "../../domain/runtime/resolution"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { contextSummary } from "../../presentation/runtime/status"; import { type ComposerMetaViewModel, ComposerShell, type ComposerShellProps } from "../../ui/composer"; -import type { ChatPanelComposerReadModel } from "../shell-read-model"; +import type { ChatPanelComposerModel } from "../shell-selectors"; type ChatPanelComposerMeta = ComposerMetaViewModel; type ChatPanelComposerContextMeter = ComposerMetaViewModel["context"]; @@ -26,7 +27,7 @@ export interface ChatPanelComposerProjectionActions { } export interface ChatPanelComposerPresenter { - renderState(model: ChatPanelComposerReadModel, actions: ChatPanelComposerActions): ComposerShellProps; + renderState(model: ChatPanelComposerModel, actions: ChatPanelComposerActions): ComposerShellProps; } export interface ChatPanelComposerActions { @@ -34,7 +35,7 @@ export interface ChatPanelComposerActions { } interface RuntimeComposerChoicesInput { - readModel: ChatPanelComposerReadModel; + model: ChatPanelComposerModel; snapshot: RuntimeSnapshot; requestModel: (model: string) => void; requestReasoningEffort: (effort: ReasoningEffort) => void; @@ -50,7 +51,7 @@ export function ChatPanelComposer({ presenter, actions, }: { - model: ChatPanelComposerReadModel; + model: ChatPanelComposerModel; presenter: ChatPanelComposerPresenter; actions: ChatPanelComposerActions; }): UiNode { @@ -58,18 +59,25 @@ export function ChatPanelComposer({ } export function chatPanelComposerProjection( - readModel: ChatPanelComposerReadModel, + model: ChatPanelComposerModel, actions: ChatPanelComposerProjectionActions, ): ChatPanelComposerProjection { - const snapshot = readModel.runtimeSnapshot.value; + const snapshot = runtimeSnapshotForChatSlices({ + runtimeConfig: model.runtimeConfig, + activeThread: { id: model.activeThreadId, tokenUsage: model.activeThreadTokenUsage }, + runtime: model.runtime, + rateLimit: model.rateLimit, + hasThreadTurns: model.hasThreadTurns, + availableModels: model.availableModels, + }); return { - placeholder: readModel.activeThreadSubagent.value + placeholder: model.activeThreadSubagent ? "Agent thread is read-only." - : composerPlaceholder(activeComposerThreadName(readModel), readModel.sideChatActive.value, readModel.sideChatSourceTitle.value), + : composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle), meta: { - ...composerMetaViewModel(readModel, snapshot), + ...composerMetaViewModel(model, snapshot), ...runtimeComposerChoices({ - readModel, + model, snapshot, requestModel: (model) => void actions.requestModel(model), requestReasoningEffort: (effort) => void actions.requestReasoningEffort(effort), @@ -79,10 +87,10 @@ export function chatPanelComposerProjection( } function composerMetaViewModel( - readModel: ChatPanelComposerReadModel, + model: ChatPanelComposerModel, snapshot: RuntimeSnapshot, ): Omit { - const phase = readModel.connection.phase.value; + const phase = model.connectionPhase; if (phase.kind === "failed" || phase.kind === "disconnected") { return { fatal: "Codex app-server disconnected", @@ -96,7 +104,7 @@ function composerMetaViewModel( }; } - const config = runtimeConfigOrDefault(readModel.connection.runtimeConfig.value); + const config = runtimeConfigOrDefault(model.runtimeConfig); const resolution = resolveRuntimeControls(snapshot, config); const context = contextSummary(snapshot); const selectedModel = resolution.model.effective; @@ -133,10 +141,10 @@ function runtimeComposerChoices(input: RuntimeComposerChoicesInput): { modelChoices: ChatPanelComposerRuntimeChoice[]; effortChoices: ChatPanelComposerRuntimeChoice[]; } { - const config = runtimeConfigOrDefault(input.readModel.connection.runtimeConfig.value); + const config = runtimeConfigOrDefault(input.model.runtimeConfig); const resolution = resolveRuntimeControls(input.snapshot, config); const effectiveModel = resolution.model.effective; - const models = sortedModelMetadata(input.readModel.connection.availableModels.value); + const models = sortedModelMetadata(input.model.availableModels); const modelChoices: ChatPanelComposerRuntimeChoice[] = models.slice(0, 12).map((model) => ({ label: model.model, selected: effectiveModel === model.model, @@ -187,9 +195,8 @@ function onOffLabel(active: boolean): string { return active ? "on" : "off"; } -function activeComposerThreadName(model: ChatPanelComposerReadModel): string | null { - const activeThreadId = model.activeThreadId.value; - return activeThreadId ? model.activeListedThreadName.value : null; +function activeComposerThreadName(model: ChatPanelComposerModel): string | null { + return model.activeThreadId ? model.activeListedThreadName : null; } const CONTEXT_DOT_WIDTH = 4; diff --git a/src/features/chat/panel/surface/goal-projection.tsx b/src/features/chat/panel/surface/goal-projection.tsx index 12a99d37..87499b19 100644 --- a/src/features/chat/panel/surface/goal-projection.tsx +++ b/src/features/chat/panel/surface/goal-projection.tsx @@ -3,7 +3,7 @@ import { h } from "preact"; import type { SendShortcut } from "../../../../domain/input/send-shortcut"; import type { GoalPanelActions, GoalPanelDisplayState, GoalPanelEditorState, GoalPanelOptions } from "../../ui/goal"; import { GoalPanel } from "../../ui/goal"; -import type { ChatPanelGoalReadModel } from "../shell-read-model"; +import type { ChatPanelGoalModel } from "../shell-selectors"; interface ChatPanelGoalActions { saveObjective: (objective: string, tokenBudget: number | null) => Promise; @@ -20,22 +20,22 @@ export interface ChatPanelGoalSurface { actions: ChatPanelGoalActions; } -export function ChatPanelGoal({ model, surface }: { model: ChatPanelGoalReadModel; surface: ChatPanelGoalSurface }): UiNode { +export function ChatPanelGoal({ model, surface }: { model: ChatPanelGoalModel; surface: ChatPanelGoalSurface }): UiNode { const props = chatPanelGoalViewModel(surface, model); return h(GoalPanel, props); } interface ChatPanelGoalProjection { - goal: ChatPanelGoalReadModel["goal"]["value"]; + goal: ChatPanelGoalModel["goal"]; goalThreadId: string | null; editor: GoalPanelEditorState; display: GoalPanelDisplayState; } -function chatPanelGoalProjection(model: ChatPanelGoalReadModel): ChatPanelGoalProjection { - const goal = model.goal.value; +function chatPanelGoalProjection(model: ChatPanelGoalModel): ChatPanelGoalProjection { + const goal = model.goal; const goalThreadId = goal?.threadId ?? null; - const goalEditor = model.goalEditor.value; + const goalEditor = model.goalEditor; const editor = goalEditor.kind === "editing" ? { editing: true, objectiveDraft: goalEditor.objectiveDraft, tokenBudgetDraft: goalEditor.tokenBudgetDraft } @@ -45,16 +45,16 @@ function chatPanelGoalProjection(model: ChatPanelGoalReadModel): ChatPanelGoalPr goalThreadId, editor, display: { - objectiveExpanded: goalThreadId ? model.goalObjectiveExpanded.value.has(goalThreadId) : false, + objectiveExpanded: goalThreadId ? model.goalObjectiveExpanded.has(goalThreadId) : false, }, }; } function chatPanelGoalViewModel( surface: ChatPanelGoalSurface, - model: ChatPanelGoalReadModel, + model: ChatPanelGoalModel, ): { - goal: ChatPanelGoalReadModel["goal"]["value"]; + goal: ChatPanelGoalModel["goal"]; actions: GoalPanelActions; options: GoalPanelOptions; editor: GoalPanelEditorState; diff --git a/src/features/chat/panel/surface/thread-stream-presenter.ts b/src/features/chat/panel/surface/thread-stream-presenter.ts index df47d8f0..5a2234f0 100644 --- a/src/features/chat/panel/surface/thread-stream-presenter.ts +++ b/src/features/chat/panel/surface/thread-stream-presenter.ts @@ -9,18 +9,18 @@ import type { ChatStateStore } from "../../application/state/store"; import type { ThreadStreamScrollPortBinding } from "../../ui/thread-stream/flow-scroll.measure"; import { renderStreamMarkdown, ThreadStreamMarkdownRenderer } from "../../ui/thread-stream/markdown-renderer.obsidian"; import { ThreadStreamViewport, type ThreadStreamViewportState } from "../../ui/thread-stream/stream-blocks"; -import type { ChatPanelThreadStreamReadModel } from "../shell-read-model"; +import type { ChatPanelThreadStreamModel } from "../shell-selectors"; import { type ChatThreadStreamSurfaceContext, threadStreamSurfaceProjectionFromModel } from "./thread-stream-projection"; export interface ChatPanelThreadStreamPresenter { - renderState(model: ChatPanelThreadStreamReadModel): ThreadStreamViewportState; + renderState(model: ChatPanelThreadStreamModel): ThreadStreamViewportState; } export function ChatPanelThreadStream({ model, presenter, }: { - model: ChatPanelThreadStreamReadModel; + model: ChatPanelThreadStreamModel; presenter: ChatPanelThreadStreamPresenter; }): UiNode { return h(ThreadStreamViewport, { @@ -112,7 +112,7 @@ export class ThreadStreamPresenter { }; } - renderState(model: ChatPanelThreadStreamReadModel): ThreadStreamViewportState { + renderState(model: ChatPanelThreadStreamModel): ThreadStreamViewportState { const projection = threadStreamSurfaceProjectionFromModel(model, this.surfaceContext); return { diff --git a/src/features/chat/panel/surface/thread-stream-projection.ts b/src/features/chat/panel/surface/thread-stream-projection.ts index a2b447ee..2c1e7b67 100644 --- a/src/features/chat/panel/surface/thread-stream-projection.ts +++ b/src/features/chat/panel/surface/thread-stream-projection.ts @@ -1,14 +1,28 @@ import type { TurnDiffViewState } from "../../../turn-diff/model"; import { type PendingRequestBlockActions, pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block"; import type { ChatRequestState } from "../../application/pending-requests/state"; -import type { ThreadStreamRollbackCandidate } from "../../application/state/thread-stream"; +import { + type ThreadStreamRollbackCandidate, + threadStreamActiveItems, + threadStreamItems, + threadStreamRollbackCandidateFromItems, + threadStreamStableItems, +} from "../../application/state/thread-stream"; +import { implementPlanTarget } from "../../application/turns/plan-implementation"; +import { activeTurnId, chatTurnBusy } from "../../application/turns/turn-state"; import { pendingRequestsSignature } from "../../domain/pending-requests/signatures"; -import { type ForkCandidate, type PlanImplementationTarget, threadStreamSegmentsEmpty } from "../../domain/thread-stream/selectors"; +import type { ThreadStreamItem } from "../../domain/thread-stream/items"; +import { + type ForkCandidate, + forkCandidatesFromItems, + type PlanImplementationTarget, + threadStreamSegmentsEmpty, +} from "../../domain/thread-stream/selectors"; import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model"; import type { ThreadStreamTextActionTargets } from "../../presentation/thread-stream/text-view"; import { type ThreadStreamViewBlock, threadStreamViewBlocks } from "../../presentation/thread-stream/view-model"; import type { ThreadStreamContext, ThreadStreamDisclosureBucket, ThreadStreamDisclosureState } from "../../ui/thread-stream/context"; -import type { ChatPanelThreadStreamReadModel } from "../shell-read-model"; +import type { ChatPanelThreadStreamModel } from "../shell-selectors"; interface ChatThreadStreamActions { rollbackThread: (threadId: string) => void; @@ -56,7 +70,7 @@ export interface ThreadStreamSurfaceProjection { } export function threadStreamSurfaceProjectionFromModel( - model: ChatPanelThreadStreamReadModel, + model: ChatPanelThreadStreamModel, context: ChatThreadStreamSurfaceContext, ): ThreadStreamSurfaceProjection { const projection = threadStreamStateProjection(model, context); @@ -114,44 +128,70 @@ function threadStreamContextFromProjection( } function threadStreamStateProjection( - model: ChatPanelThreadStreamReadModel, + model: ChatPanelThreadStreamModel, context: ChatThreadStreamSurfaceContext, ): ThreadStreamStateProjection { - const stableItems = model.stableItems.value; - const activeItems = model.activeItems.value; - const disclosures = model.disclosures.value; - const workspaceRoot = model.activeThreadCwd.value ?? context.vaultPath; - const textActionTargetsByItemId = textActionTargetsForThreadStreamItems( - model.rollbackCandidate.value, - model.forkCandidates.value, - model.implementPlanTarget.value, - ); + const canonicalItems = threadStreamItems(model.threadStream); + const stableItems = model.threadStream.activeSegment + ? threadStreamStableItems(model.threadStream) + : appendPendingSubmission(threadStreamStableItems(model.threadStream), model.pendingSubmission); + const activeItems = model.threadStream.activeSegment + ? appendPendingSubmission(threadStreamActiveItems(model.threadStream), model.pendingSubmission) + : threadStreamActiveItems(model.threadStream); + const items = appendPendingSubmission(canonicalItems, model.pendingSubmission); + const disclosures = { + details: model.disclosureDetails, + activityGroups: model.disclosureActivityGroups, + textDetails: model.disclosureTextDetails, + userDialogueExpanded: model.disclosureUserDialogueExpanded, + approvalDetails: model.disclosureApprovalDetails, + }; + const workspaceRoot = model.activeThreadCwd ?? context.vaultPath; + const turnBusy = chatTurnBusy(model.turn); + const actionCandidatesAllowed = + !turnBusy && model.activeThreadLifetime?.kind !== "ephemeral" && model.activeThreadProvenance?.kind !== "subagent"; + const rollbackCandidate = actionCandidatesAllowed ? threadStreamRollbackCandidateFromItems(canonicalItems) : null; + const forkCandidates = actionCandidatesAllowed ? forkCandidatesFromItems(canonicalItems) : []; + const planTarget = implementPlanTarget({ + activeThread: model.activeThreadId ? { id: model.activeThreadId, provenance: model.activeThreadProvenance } : null, + turn: model.turn, + runtime: { pending: { collaborationMode: model.runtimeCollaborationMode } }, + threadStream: model.threadStream, + }); + const textActionTargetsByItemId = textActionTargetsForThreadStreamItems(rollbackCandidate, forkCandidates, planTarget); const pendingRequests = threadStreamSegmentsEmpty(stableItems, activeItems) ? null - : pendingRequestSurfaceProjectionFromState(model.requests.value, disclosures.approvalDetails); + : pendingRequestSurfaceProjectionFromState(model.requests, disclosures.approvalDetails); return { - activeThreadId: model.activeThreadId.value, + activeThreadId: model.activeThreadId, workspaceRoot, disclosures, - forkMenuItemId: model.forkMenuItemId.value, + forkMenuItemId: model.forkMenuItemId, pendingRequests, viewBlocks: threadStreamViewBlocks({ - activeThreadId: model.activeThreadId.value, - activeTurnId: model.activeTurnId.value, - historyCursor: model.historyCursor.value, - loadingHistory: model.loadingHistory.value, - items: model.items.value, + activeThreadId: model.activeThreadId, + activeTurnId: activeTurnId(model.turn), + historyCursor: model.threadStream.historyCursor, + loadingHistory: model.threadStream.loadingHistory, + items, stableItems, activeItems, workspaceRoot, - turnDiffs: model.turnDiffs.value, + turnDiffs: model.threadStream.turnDiffs, textActionTargetsByItemId, pendingRequests, }), }; } +function appendPendingSubmission( + items: readonly ThreadStreamItem[], + pendingSubmission: ChatPanelThreadStreamModel["pendingSubmission"], +): readonly ThreadStreamItem[] { + return pendingSubmission ? [...items, pendingSubmission.item] : items; +} + function textActionTargetsForThreadStreamItems( rollbackCandidate: ThreadStreamRollbackCandidate | null, forkCandidates: readonly ForkCandidate[], diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index c81e3f59..271578a5 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -4,13 +4,16 @@ import { h } from "preact"; import { CLIENT_VERSION } from "../../../../constants"; import type { Thread } from "../../../../domain/threads/model"; import { threadRowCoreProjection } from "../../../threads/list/row-projection"; +import { runtimeSnapshotForChatSlices } from "../../application/runtime/snapshot"; +import { activeThreadState } from "../../application/state/root-reducer"; +import type { ChatStateStore } from "../../application/state/store"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { appServerDiagnosticSections } from "../../presentation/runtime/diagnostic-sections"; import { runtimePermissionSections } from "../../presentation/runtime/permission-sections"; import { rateLimitSummary } from "../../presentation/runtime/status"; import { toolInventoryDiagnosticSections } from "../../presentation/runtime/tool-inventory-diagnostic-sections"; import { Toolbar, type ToolbarActions, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar"; -import type { ChatPanelToolbarReadModel } from "../shell-read-model"; +import type { ChatPanelToolbarModel } from "../shell-selectors"; export interface ChatPanelToolbarSurface { connection: { @@ -27,7 +30,8 @@ export interface ChatPanelToolbarSurface { } interface ToolbarViewModelInput { - model: ChatPanelToolbarReadModel; + model: ChatPanelToolbarModel; + stateStore: ChatStateStore; snapshot: RuntimeSnapshot; connected: boolean; nowMs: number; @@ -47,13 +51,21 @@ interface ToolbarStateProjection { threads: ToolbarThreadRow[]; } -function chatPanelToolbarViewModel(surface: ChatPanelToolbarSurface, model: ChatPanelToolbarReadModel) { +function chatPanelToolbarViewModel(surface: ChatPanelToolbarSurface, model: ChatPanelToolbarModel, stateStore: ChatStateStore) { return chatPanelToolbarProjection({ model, - snapshot: model.runtimeSnapshot.value, + stateStore, + snapshot: runtimeSnapshotForChatSlices({ + runtimeConfig: model.connection.runtimeConfig, + activeThread: { id: model.activeThreadId, tokenUsage: model.activeThreadTokenUsage }, + runtime: model.runtime, + rateLimit: model.connection.rateLimit, + hasThreadTurns: false, + availableModels: model.connection.availableModels, + }), connected: surface.connection.connected(), nowMs: surface.clock.nowMs(), - turnBusy: model.turnBusy.value, + turnBusy: model.turnBusy, vaultPath: surface.settings.vaultPath(), configuredCommand: surface.settings.configuredCommand(), archiveExportEnabled: surface.settings.archiveExportEnabled(), @@ -62,21 +74,23 @@ function chatPanelToolbarViewModel(surface: ChatPanelToolbarSurface, model: Chat export function ChatPanelToolbar({ model, + stateStore, surface, actions, }: { - model: ChatPanelToolbarReadModel; + model: ChatPanelToolbarModel; + stateStore: ChatStateStore; surface: ChatPanelToolbarSurface; actions: ToolbarActions; }): UiNode { - return h(Toolbar, { model: chatPanelToolbarViewModel(surface, model), actions }); + return h(Toolbar, { model: chatPanelToolbarViewModel(surface, model, stateStore), actions }); } function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewModel { const { model, snapshot } = input; const projection = toolbarStateProjection(input); const limit = rateLimitSummary(snapshot, input.nowMs); - const diagnostics = model.diagnostics.value; + const diagnostics = model.connection; const permissions = runtimePermissionSections({ snapshot, vaultPath: input.vaultPath, @@ -104,14 +118,15 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo } function runtimeDebugDetails(input: ToolbarViewModelInput): string { - const debug = input.model.debug.value; - const connection = debug.connection; + const state = input.stateStore.getState(); + const activeThread = activeThreadState(state); + const connection = state.connection; return JSON.stringify( { clientVersion: CLIENT_VERSION, vaultPath: input.vaultPath, configuredCommand: input.configuredCommand, - activeThreadId: debug.activeThreadId, + activeThreadId: activeThread?.id ?? null, connection: { connected: input.connected, phase: connection.phase, @@ -123,9 +138,9 @@ function runtimeDebugDetails(input: ToolbarViewModelInput): string { mcpServers: connection.serverDiagnostics.mcpServers, }, }, - runtimeConfig: debug.runtimeConfig, - runtime: debug.runtime, - availableModels: debug.availableModels, + runtimeConfig: connection.runtimeConfig, + runtime: state.runtime, + availableModels: connection.availableModels, }, null, 2, @@ -133,28 +148,28 @@ function runtimeDebugDetails(input: ToolbarViewModelInput): string { } function toolbarStateProjection(input: { - model: ChatPanelToolbarReadModel; + model: ChatPanelToolbarModel; turnBusy: boolean; archiveExportEnabled: boolean; }): ToolbarStateProjection { - const toolbarPanel = input.model.toolbarPanel.value; + const toolbarPanel = input.model.toolbarPanel; const historyOpen = toolbarPanel === "history"; const chatActionsOpen = toolbarPanel === "chat-actions"; const statusPanelOpen = toolbarPanel === "status-panel"; return { - newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent.value, - activeThreadChatActionsDisabled: input.model.activeThreadSubagent.value, + newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent, + activeThreadChatActionsDisabled: input.model.activeThreadSubagent, chatActionsOpen, historyOpen, statusPanelOpen, openPanel: historyOpen ? "history" : chatActionsOpen ? "chat-actions" : statusPanelOpen ? "status" : null, threads: toolbarThreadRows({ - threads: input.model.threads.value, - activeThreadId: input.model.activeThreadId.value, + threads: input.model.threads, + activeThreadId: input.model.activeThreadId, turnBusy: input.turnBusy, - archiveConfirmThreadId: input.model.archiveConfirmThreadId.value, + archiveConfirmThreadId: input.model.archiveConfirmThreadId, archiveExportEnabled: input.archiveExportEnabled, - renameState: input.model.rename.value, + renameState: input.model.rename, }), }; } @@ -165,7 +180,7 @@ function toolbarThreadRows(input: { turnBusy: boolean; archiveConfirmThreadId: string | null; archiveExportEnabled: boolean; - renameState: ChatPanelToolbarReadModel["rename"]["value"]; + renameState: ChatPanelToolbarModel["rename"]; }): ToolbarThreadRow[] { return input.threads.map((thread) => { const threadId = thread.id; @@ -188,7 +203,7 @@ function toolbarThreadRows(input: { }); } -function toolbarActiveRenameState(renameState: ChatPanelToolbarReadModel["rename"]["value"], threadId: string) { +function toolbarActiveRenameState(renameState: ChatPanelToolbarModel["rename"], threadId: string) { if (renameState.kind === "idle" || renameState.threadId !== threadId) return undefined; return renameState; } diff --git a/tests/features/chat/panel/chat-state-selector.test.tsx b/tests/features/chat/panel/chat-state-selector.test.tsx new file mode 100644 index 00000000..8b77eda8 --- /dev/null +++ b/tests/features/chat/panel/chat-state-selector.test.tsx @@ -0,0 +1,196 @@ +// @vitest-environment jsdom + +import type { ComponentChild } from "preact"; +import { act } from "preact/test-utils"; +import { describe, expect, it, type Mock, vi } from "vitest"; +import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer"; +import type { ChatStateStore } from "../../../../src/features/chat/application/state/store"; +import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; +import { useChatSelector } from "../../../../src/features/chat/panel/chat-state-selector"; +import { + selectChatPanelComposer, + selectChatPanelGoal, + selectChatPanelThreadStream, + selectChatPanelToolbar, +} from "../../../../src/features/chat/panel/shell-selectors"; +import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-root.dom"; +import { chatStateFixture, chatStateWith } from "../support/state"; + +describe("useChatSelector", () => { + it("catches an update between the render read and subscription", async () => { + const initial = chatStateFixture(); + const updated = chatStateWith(initial, { composer: { draft: "after subscribe" } }); + const store = controllableStore(initial, (current) => { + current.replaceWithoutNotification(updated); + }); + const parent = document.createElement("div"); + + await act(async () => { + renderUiRoot(parent, ); + }); + + expect(parent.textContent).toBe("after subscribe"); + unmountUiRoot(parent); + }); + + it("unsubscribes on unmount", async () => { + const store = controllableStore(chatStateFixture()); + const parent = document.createElement("div"); + + await act(async () => { + renderUiRoot(parent, ); + }); + expect(store.listenerCount()).toBe(1); + + await act(async () => { + unmountUiRoot(parent); + }); + expect(store.listenerCount()).toBe(0); + }); + + it("moves the subscription when the store is replaced", async () => { + const first = controllableStore(chatStateWith(chatStateFixture(), { composer: { draft: "first" } })); + const second = controllableStore(chatStateWith(chatStateFixture(), { composer: { draft: "second" } })); + const parent = document.createElement("div"); + + await act(async () => { + renderUiRoot(parent, ); + renderUiRoot(parent, ); + }); + + expect(first.listenerCount()).toBe(0); + expect(second.listenerCount()).toBe(1); + expect(parent.textContent).toBe("second"); + + await act(async () => { + first.replace(chatStateWith(first.getState(), { composer: { draft: "stale" } })); + second.replace(chatStateWith(second.getState(), { composer: { draft: "current" } })); + }); + expect(parent.textContent).toBe("current"); + unmountUiRoot(parent); + }); + + it("rerenders only the region whose selected state changed", async () => { + const store = createChatStateStore(); + const renders = { toolbar: vi.fn(), goal: vi.fn(), threadStream: vi.fn(), composer: vi.fn() }; + const parent = document.createElement("div"); + + await act(async () => { + renderUiRoot(parent, ); + }); + expect(regionRenderCounts(renders)).toEqual([1, 1, 1, 1]); + + await act(async () => { + store.dispatch({ type: "composer/draft-set", draft: "draft" }); + }); + expect(regionRenderCounts(renders)).toEqual([1, 1, 1, 2]); + + await act(async () => { + store.dispatch({ type: "ui/goal-editor-started", threadId: null, objective: "Goal", tokenBudget: null }); + }); + expect(regionRenderCounts(renders)).toEqual([1, 2, 1, 2]); + + await act(async () => { + store.dispatch({ type: "ui/panel-set", panel: "history" }); + }); + expect(regionRenderCounts(renders)).toEqual([2, 2, 1, 2]); + + await act(async () => { + store.dispatch({ type: "ui/disclosure-set", bucket: "details", id: "item", open: true }); + }); + expect(regionRenderCounts(renders)).toEqual([2, 2, 2, 2]); + + await act(async () => { + store.dispatch({ + type: "thread-stream/system-item-added", + item: { id: "status", kind: "system", role: "system", text: "Status" }, + }); + }); + expect(regionRenderCounts(renders)).toEqual([2, 2, 3, 2]); + + await act(async () => { + store.dispatch({ + type: "thread-stream/items-replaced", + historyCursor: null, + items: [{ id: "user", kind: "dialogue", dialogueKind: "user", role: "user", text: "Hello", turnId: "turn" }], + }); + }); + expect(regionRenderCounts(renders)).toEqual([2, 2, 4, 3]); + unmountUiRoot(parent); + }); +}); + +function ComposerValue({ store }: { store: ChatStateStore }): ComponentChild { + return useChatSelector(store, selectChatPanelComposer).draft; +} + +function SelectorRegions({ + store, + renders, +}: { + store: ChatStateStore; + renders: Record<"toolbar" | "goal" | "threadStream" | "composer", Mock<() => void>>; +}): ComponentChild { + return ( + <> + + + + + + ); +} + +function Region({ + store, + selector, + rendered, +}: { + store: ChatStateStore; + selector: (state: ChatState) => Selection; + rendered: Mock<() => void>; +}): ComponentChild { + useChatSelector(store, selector); + rendered(); + return null; +} + +function regionRenderCounts(renders: { + toolbar: Mock<() => void>; + goal: Mock<() => void>; + threadStream: Mock<() => void>; + composer: Mock<() => void>; +}): number[] { + return [renders.toolbar, renders.goal, renders.threadStream, renders.composer].map((rendered) => rendered.mock.calls.length); +} + +interface ControllableStore extends ChatStateStore { + listenerCount(): number; + replace(state: ChatState): void; + replaceWithoutNotification(state: ChatState): void; +} + +function controllableStore(initialState: ChatState, onSubscribe?: (store: ControllableStore) => void): ControllableStore { + let state = initialState; + const listeners = new Set<() => void>(); + const store: ControllableStore = { + getState: () => state, + dispatch: () => state, + subscribe(listener) { + listeners.add(listener); + onSubscribe?.(store); + return () => { + listeners.delete(listener); + }; + }, + listenerCount: () => listeners.size, + replace(nextState) { + state = nextState; + for (const listener of listeners) listener(); + }, + replaceWithoutNotification(nextState) { + state = nextState; + }, + }; + return store; +} diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 9def8381..2b152344 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -11,15 +11,16 @@ import type { import type { NoteCandidateProvider } from "../../../../src/features/chat/application/composer/note-context"; import type { ChatStateStore } from "../../../../src/features/chat/application/state/store"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; +import { threadStreamItems } from "../../../../src/features/chat/application/state/thread-stream"; import { pendingWebSubmissionItem } from "../../../../src/features/chat/application/turns/web-submission"; import type { ThreadStreamItem } from "../../../../src/features/chat/domain/thread-stream/items"; import { ChatComposerController, type ChatComposerRenderActions } from "../../../../src/features/chat/panel/composer-controller"; -import type { ChatPanelComposerReadModel } from "../../../../src/features/chat/panel/shell-read-model"; +import type { ChatPanelComposerModel } from "../../../../src/features/chat/panel/shell-selectors"; import { ComposerShell } from "../../../../src/features/chat/ui/composer"; import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-root.dom"; import { deferred } from "../../../support/async"; import { installObsidianDomShims } from "../../../support/dom"; -import { composerReadModelFromChatState, threadStreamReadModelFromChatState } from "../support/shell-read-model"; +import { composerModelFromChatState } from "../support/shell-selectors"; import { chatStateFixture, chatStateWith } from "../support/state"; installObsidianDomShims(); @@ -40,7 +41,7 @@ function renderComposerController( actions: ChatComposerRenderActions = { submit: vi.fn() }, ): void { renderedComposerParents.add(parent); - renderUiRoot(parent, h(ComposerShell, controller.renderState(composerReadModelFromChatState(stateStore.getState()), actions))); + renderUiRoot(parent, h(ComposerShell, controller.renderState(composerModelFromChatState(stateStore.getState()), actions))); } function trackComposerControllerTestCleanup(cleanup: () => void): void { @@ -100,7 +101,7 @@ describe("ChatComposerController", () => { }, }); - const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() }); + const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() }); expect(props.submissionDisabled).toBe(true); expect(props.webSubmissionCancellable).toBe(true); @@ -121,7 +122,7 @@ describe("ChatComposerController", () => { }, } as never); - const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() }); + const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() }); expect(props.submissionDisabled).toBe(true); expect(props.webSubmissionCancellable).toBe(false); @@ -153,20 +154,18 @@ describe("ChatComposerController", () => { }); stateStore.dispatch({ type: "turn/completed", turnId: "turn", status: "completed", items: [assistant] }); - const model = threadStreamReadModelFromChatState(stateStore.getState()); - expect(model.items.value.map((item) => item.id)).toEqual(["assistant", pending.id]); - expect(model.stableItems.value.map((item) => item.id)).toEqual(["assistant", pending.id]); + expect(threadStreamItems(stateStore.getState().threadStream).map((item) => item.id)).toEqual(["assistant"]); expect(stateStore.getState().pendingSubmission?.id).toBe(pending.id); }); it("derives composer placeholder and meta from the projection", () => { - const projection = vi.fn((model: ChatPanelComposerReadModel) => ({ - placeholder: `Projected ${model.draft.value || "empty"}`, + const projection = vi.fn((model: ChatPanelComposerModel) => ({ + placeholder: `Projected ${model.draft || "empty"}`, meta: defaultComposerProjection(model).meta, })); const { controller, stateStore } = composerControllerFixture({ controller: { composerProjection: projection } }); - const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() }); + const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() }); expect(props.normalPlaceholder).toBe("Projected empty"); expect(props.meta.statusSummary).toBe( @@ -1064,7 +1063,7 @@ function skill(name: string): SkillMetadata { }; } -function defaultComposerProjection(_model: ChatPanelComposerReadModel) { +function defaultComposerProjection(_model: ChatPanelComposerModel) { return { placeholder: "Ask Codex...", meta: { diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index b1eafdb2..31c88f54 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -7,7 +7,7 @@ import type { NoteCandidateProvider } from "../../../../src/features/chat/applic import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller"; import { type ChatPanelShellParts, renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/panel/shell.dom"; -import type { ChatPanelComposerReadModel } from "../../../../src/features/chat/panel/shell-read-model"; +import type { ChatPanelComposerModel } from "../../../../src/features/chat/panel/shell-selectors"; import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection"; import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection"; import { threadStreamViewBlocks } from "../../../../src/features/chat/presentation/thread-stream/view-model"; @@ -69,7 +69,7 @@ describe("ChatPanelShell", () => { }); }); - it("keeps Tab wikilink insertion before closing brackets through shell signal updates", async () => { + it("keeps Tab wikilink insertion before closing brackets through shell selector updates", async () => { const store = createChatStateStore(); const container = document.createElement("div"); document.body.appendChild(container); @@ -293,14 +293,14 @@ function shellParts( goal: surface.goal, threadStream: { renderState: (model) => { - void model.activeThreadCwd.value; + void model.activeThreadCwd; return { blocks: threadStreamViewBlocks({ - activeThreadId: model.activeThreadId.value, + activeThreadId: model.activeThreadId, activeTurnId: null, - historyCursor: model.historyCursor.value, - loadingHistory: model.loadingHistory.value, - items: model.items.value, + historyCursor: model.threadStream.historyCursor, + loadingHistory: model.threadStream.loadingHistory, + items: model.threadStream.stableItems, }), context: testThreadStreamContext, scrollPortBinding: noOpThreadStreamScrollPortBinding, @@ -310,10 +310,10 @@ function shellParts( composer: { presenter: { renderState: (model) => { - void model.runtimeSnapshot.value; + void model.runtime; return { viewId: "view", - draft: model.draft.value, + draft: model.draft, busy: false, canInterrupt: false, submissionDisabled: false, @@ -393,7 +393,7 @@ function contextProvider( }; } -function composerProjectionFixture(_model: ChatPanelComposerReadModel) { +function composerProjectionFixture(_model: ChatPanelComposerModel) { return { placeholder: "Ask Codex...", meta: { diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 8c5fccf0..739baded 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -9,16 +9,26 @@ import { createServerDiagnostics } from "../../../../../src/domain/server/diagno import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; import type { Thread } from "../../../../../src/domain/threads/model"; import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer"; +import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent"; -import { createChatPanelShellReadModelBinding } from "../../../../../src/features/chat/panel/shell-read-model"; +import { + selectChatPanelComposer, + selectChatPanelGoal, + selectChatPanelThreadStream, + selectChatPanelToolbar, +} from "../../../../../src/features/chat/panel/shell-selectors"; import type { ChatPanelComposerProjectionActions } from "../../../../../src/features/chat/panel/surface/composer-projection"; import { chatPanelComposerProjection } from "../../../../../src/features/chat/panel/surface/composer-projection"; import { ChatPanelGoal, type ChatPanelGoalSurface } from "../../../../../src/features/chat/panel/surface/goal-projection"; +import { + type ChatThreadStreamSurfaceContext, + threadStreamSurfaceProjectionFromModel, +} from "../../../../../src/features/chat/panel/surface/thread-stream-projection"; import { ChatPanelToolbar } from "../../../../../src/features/chat/panel/surface/toolbar-projection"; import type { ToolbarActions } from "../../../../../src/features/chat/ui/toolbar"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom"; import { installObsidianDomShims } from "../../../../support/dom"; -import { composerReadModelFromChatState } from "../../support/shell-read-model"; +import { composerModelFromChatState } from "../../support/shell-selectors"; import { chatStateFixture, chatStateWith } from "../../support/state"; import { withChatStateThreadStreamItems } from "../../support/thread-stream"; @@ -44,8 +54,8 @@ describe("chat panel surface projections", () => { ui: { toolbarPanel: "chat-actions" }, }); const actions = toolbarActionsFixture(); - const parent = renderWithShellReadModel(state, (readModel) => - h(ChatPanelToolbar, { model: readModel.toolbar, surface: toolbarSurfaceFixture(), actions }), + const parent = renderWithShellModels(state, (models) => + h(ChatPanelToolbar, { model: models.toolbar, stateStore: createChatStateStore(state), surface: toolbarSurfaceFixture(), actions }), ); const items = [...parent.querySelectorAll(".codex-panel__chat-actions-panel-item")]; @@ -79,7 +89,8 @@ describe("chat panel surface projections", () => { }, ]); - expect(createChatPanelShellReadModelBinding(state).readModel.threadStream.rollbackCandidate.value).toBeNull(); + const projection = threadStreamSurfaceProjectionFromModel(selectChatPanelThreadStream(state), threadStreamSurfaceContext()); + expect(JSON.stringify(projection.blocks)).not.toContain('"rollback":true'); }); it("builds toolbar rows from immutable chat state snapshots", () => { @@ -97,9 +108,10 @@ describe("chat panel surface projections", () => { }); state = chatStateWith(state, { connection: { serverDiagnostics: createServerDiagnostics() } }); - const parent = renderWithShellReadModel(state, (readModel) => + const parent = renderWithShellModels(state, (models) => h(ChatPanelToolbar, { - model: readModel.toolbar, + model: models.toolbar, + stateStore: createChatStateStore(state), surface: toolbarSurfaceFixture({ archiveExportEnabled: true }), actions: toolbarActionsFixture(), }), @@ -128,15 +140,18 @@ describe("chat panel surface projections", () => { }); state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-debug")] } }); state = chatStateWith(state, { runtime: { pending: { model: { kind: "set", value: "gpt-debug" } } } }); + const stateStore = createChatStateStore(state); const copyDebugDetails = vi.fn<(details: string) => void>(); - const parent = renderWithShellReadModel(state, (readModel) => + const parent = renderWithShellModels(state, (models) => h(ChatPanelToolbar, { - model: readModel.toolbar, + model: models.toolbar, + stateStore, surface: toolbarSurfaceFixture(), actions: toolbarActionsFixture({ copyDebugDetails }), }), ); + stateStore.dispatch({ type: "runtime/model-requested", model: "gpt-live" }); parent.querySelectorAll(".codex-panel__status-panel-item")[2]?.click(); const debugContent = copyDebugDetails.mock.calls[0]?.[0]; @@ -162,7 +177,7 @@ describe("chat panel surface projections", () => { (debugDetails["connection"] as { serverDiagnostics?: Record }).serverDiagnostics?.["toolInventory"], ).toBeUndefined(); expect(debugDetails["runtimeConfig"]).toMatchObject({ model: "gpt-debug" }); - expect(debugDetails["runtime"]).toMatchObject({ pending: { model: { kind: "set", value: "gpt-debug" } } }); + expect(debugDetails["runtime"]).toMatchObject({ pending: { model: { kind: "set", value: "gpt-live" } } }); expect(debugDetails["runtimeLayers"]).toBeUndefined(); expect(debugDetails["runtimeResolution"]).toBeUndefined(); expect(debugDetails["availableModels"]).toMatchObject([{ model: "gpt-debug" }]); @@ -191,9 +206,10 @@ describe("chat panel surface projections", () => { }, }); - const parent = renderWithShellReadModel(state, (readModel) => + const parent = renderWithShellModels(state, (models) => h(ChatPanelToolbar, { - model: readModel.toolbar, + model: models.toolbar, + stateStore: createChatStateStore(state), surface: toolbarSurfaceFixture(), actions: toolbarActionsFixture(), }), @@ -414,13 +430,13 @@ describe("chat panel surface projections", () => { }, } satisfies ChatPanelGoalSurface; - const parent = renderWithShellReadModel(state, (readModel) => h(ChatPanelGoal, { model: readModel.goal, surface })); + const parent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface })); state = chatStateWith(state, { activeThread: { id: "thread-current" } }); clickLabeledButton(parent, "Pause goal"); clickLabeledButton(parent, "Clear goal"); state = chatStateWith(state, { activeThread: { goal: { ...goalFixture("thread-rendered"), status: "paused" } } }); - const resumeParent = renderWithShellReadModel(state, (readModel) => h(ChatPanelGoal, { model: readModel.goal, surface })); + const resumeParent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface })); clickLabeledButton(resumeParent, "Resume goal"); expect(statuses).toEqual([ @@ -483,26 +499,61 @@ describe("chat panel surface projections", () => { }); state = chatStateWith(state, { ui: { disclosures: { goalObjectiveExpanded: new Set(["thread-1"]) } } }); - const parent = renderWithShellReadModel(state, (readModel) => - h(ChatPanelGoal, { model: readModel.goal, surface: goalSurfaceFixture() }), - ); + const parent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface: goalSurfaceFixture() })); expect(parent.querySelector(".codex-panel__goal-objective-input")?.value).toBe("Draft goal"); unmountUiRoot(parent); }); }); -function renderWithShellReadModel( - state: ChatState, - node: (readModel: ReturnType["readModel"]) => ComponentChild, -): HTMLElement { +function renderWithShellModels(state: ChatState, node: (models: ReturnType) => ComponentChild): HTMLElement { const parent = document.createElement("div"); - renderUiRoot(parent, node(createChatPanelShellReadModelBinding(state).readModel)); + renderUiRoot(parent, node(shellModelsFromState(state))); return parent; } +function shellModelsFromState(state: ChatState) { + return { + toolbar: selectChatPanelToolbar(state), + goal: selectChatPanelGoal(state), + threadStream: selectChatPanelThreadStream(state), + composer: selectChatPanelComposer(state), + }; +} + +function threadStreamSurfaceContext(): ChatThreadStreamSurfaceContext { + return { + panelId: "test-panel", + vaultPath: "/vault", + setDisclosureOpen: vi.fn(), + setForkMenuItem: vi.fn(), + loadOlderTurns: vi.fn(), + renderObsidianMarkdown: vi.fn(), + renderStreamMarkdown: vi.fn(), + copyDialogueText: vi.fn(), + actions: { + rollbackThread: vi.fn(), + forkThreadFromTurn: vi.fn(), + implementPlan: vi.fn(), + openThreadInNewView: vi.fn(), + openTurnDiff: vi.fn(), + }, + requests: { + pendingActions: () => ({ + resolveApproval: vi.fn(), + resolveUserInput: vi.fn(), + cancelUserInput: vi.fn(), + resolveMcpElicitation: vi.fn(), + setUserInputDraft: vi.fn(), + setMcpElicitationDraft: vi.fn(), + }), + consumePendingAutoFocus: () => false, + }, + }; +} + function composerProjectionFromState(actions: ChatPanelComposerProjectionActions, state: ChatState) { - return chatPanelComposerProjection(composerReadModelFromChatState(state), actions); + return chatPanelComposerProjection(composerModelFromChatState(state), actions); } function clickLabeledButton(parent: HTMLElement, label: string): void { diff --git a/tests/features/chat/panel/surface/thread-stream-presenter.test.ts b/tests/features/chat/panel/surface/thread-stream-presenter.test.ts index 428a5bbb..6ac9305a 100644 --- a/tests/features/chat/panel/surface/thread-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/thread-stream-presenter.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { type ChatAction, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission"; import { ThreadStreamPresenter } from "../../../../../src/features/chat/panel/surface/thread-stream-presenter"; import { type ChatThreadStreamSurfaceContext, @@ -20,7 +21,7 @@ import { ThreadStreamViewport } from "../../../../../src/features/chat/ui/thread import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom"; import { notices } from "../../../../mocks/obsidian"; import { installObsidianDomShims } from "../../../../support/dom"; -import { threadStreamReadModelFromChatState } from "../../support/shell-read-model"; +import { threadStreamModelFromChatState } from "../../support/shell-selectors"; import { chatStateFixture, chatStateWith } from "../../support/state"; import { withChatStateThreadStreamItems } from "../../support/thread-stream"; import { installThreadStreamViewportMetrics, pendingApproval } from "../../ui/thread-stream/test-helpers"; @@ -30,7 +31,7 @@ const ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT = 96; installObsidianDomShims(); function renderThreadStreamPresenter(parent: HTMLElement, presenter: ThreadStreamPresenter, state: ChatState): void { - renderUiRoot(parent, h(ThreadStreamViewport, { state: presenter.renderState(threadStreamReadModelFromChatState(state)) })); + renderUiRoot(parent, h(ThreadStreamViewport, { state: presenter.renderState(threadStreamModelFromChatState(state)) })); } describe("ThreadStreamPresenter scroll pinning", () => { @@ -65,7 +66,7 @@ describe("ThreadStreamPresenter scroll pinning", () => { }); const projection = threadStreamSurfaceProjectionFromModel( - threadStreamReadModelFromChatState(store.getState()), + threadStreamModelFromChatState(store.getState()), testThreadStreamSurfaceContext({ vaultPath: "/vault", dispatch: (action) => { @@ -90,7 +91,7 @@ describe("ThreadStreamPresenter scroll pinning", () => { }, }); - const context = threadStreamSurfaceProjectionFromModel(threadStreamReadModelFromChatState(store.getState()), surfaceContext).context; + const context = threadStreamSurfaceProjectionFromModel(threadStreamModelFromChatState(store.getState()), surfaceContext).context; if (!context.onDisclosureToggle) throw new Error("Expected thread stream disclosure action"); context.onDisclosureToggle("textDetails", "message:details", true); @@ -102,7 +103,7 @@ describe("ThreadStreamPresenter scroll pinning", () => { state = withChatStateThreadStreamItems(state, [{ id: "system", kind: "system", role: "system", text: "Waiting for approval." }]); state = chatStateWith(state, { requests: { approvals: [pendingApproval()] } }); const projection = threadStreamSurfaceProjectionFromModel( - threadStreamReadModelFromChatState(state), + threadStreamModelFromChatState(state), testThreadStreamSurfaceContext({ vaultPath: "/vault", dispatch: () => undefined, @@ -114,6 +115,41 @@ describe("ThreadStreamPresenter scroll pinning", () => { expect(projection.context.pendingRequests?.snapshot().approvals).toHaveLength(1); }); + it("keeps a pending web submission visible after the canonical turn completes", () => { + const pending = pendingWebSubmissionItem("pending-web", "https://example.com", "Summarize"); + if (!pending) throw new Error("Expected pending web submission item"); + const store = createChatStateStore( + withChatStateThreadStreamItems(chatStateFixture(), [ + { + id: "assistant", + kind: "dialogue", + dialogueKind: "assistantResponse", + dialogueState: "completed", + role: "assistant", + text: "Previous answer", + turnId: "turn", + }, + ]), + ); + store.dispatch({ + type: "web-submission/pending", + submission: { + id: pending.id, + item: pending, + targetThreadId: "thread", + originalDraft: "/web https://example.com Summarize", + phase: "cancellable", + }, + }); + + const projection = threadStreamSurfaceProjectionFromModel( + threadStreamModelFromChatState(store.getState()), + testThreadStreamSurfaceContext({ vaultPath: "/vault", dispatch: () => undefined }), + ); + + expect(JSON.stringify(projection.blocks)).toContain(pending.id); + }); + it("normalizes rendered internal links that point at absolute vault paths", async () => { const openLinkText = vi.fn(); const context = markdownLinkContext(openLinkText, "/Users/showhey/Vault", ["docs/Guide.md"]); diff --git a/tests/features/chat/support/shell-read-model.ts b/tests/features/chat/support/shell-read-model.ts deleted file mode 100644 index 251b0511..00000000 --- a/tests/features/chat/support/shell-read-model.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer"; -import { createChatPanelShellReadModelBinding } from "../../../../src/features/chat/panel/shell-read-model"; - -export function composerReadModelFromChatState(state: ChatState) { - return createChatPanelShellReadModelBinding(state).readModel.composer; -} - -export function threadStreamReadModelFromChatState(state: ChatState) { - return createChatPanelShellReadModelBinding(state).readModel.threadStream; -} diff --git a/tests/features/chat/support/shell-selectors.ts b/tests/features/chat/support/shell-selectors.ts new file mode 100644 index 00000000..80a17013 --- /dev/null +++ b/tests/features/chat/support/shell-selectors.ts @@ -0,0 +1,10 @@ +import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer"; +import { selectChatPanelComposer, selectChatPanelThreadStream } from "../../../../src/features/chat/panel/shell-selectors"; + +export function composerModelFromChatState(state: ChatState) { + return selectChatPanelComposer(state); +} + +export function threadStreamModelFromChatState(state: ChatState) { + return selectChatPanelThreadStream(state); +} diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index a2e22cba..280b636d 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -149,24 +149,6 @@ const policyCases = [ 'import type { Store } from "../application/state/store";', 'import type { View } from "../presentation/thread-stream/view-model";', ), - policyCase( - "no-preact-signal-imports.grit", - "src/settings/escape.ts", - 'import { signal } from "@preact/signals";', - "export const value = 1;", - ), - policyCase( - "no-chat-shell-read-model-imports.grit", - "src/features/chat/presentation/escape.ts", - 'import type { ReadModel } from "../panel/shell-read-model";', - "export type Value = string;", - ), - policyCase( - "no-chat-signal-type-references.grit", - "src/features/chat/presentation/escape.ts", - "export type Value = Signal;", - "export type Value = string;", - ), policyCase( "no-state-module-side-effects.grit", "src/features/chat/application/state/escape.ts",