From 9447d53d570f2a0ae3594a5b4b8da45551d36ec6 Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 29 Jun 2026 21:08:29 +0900 Subject: [PATCH] Introduce chat panel read models --- biome.jsonc | 8 +- docs/design.md | 2 +- docs/development.md | 2 +- .../connection/diagnostic-sections.ts | 20 +- .../application/pending-requests/block.ts | 19 +- .../chat/application/state/ui-state.ts | 2 +- src/features/chat/host/composer-bundle.ts | 18 +- src/features/chat/host/session-graph.ts | 1 - src/features/chat/host/session.ts | 4 + src/features/chat/host/shell-bundle.ts | 4 +- .../chat/panel/composer-controller.ts | 20 +- .../chat/panel/runtime-status-projection.ts | 8 +- src/features/chat/panel/shell-read-model.ts | 364 ++++++++++++++++++ src/features/chat/panel/shell-state.tsx | 219 ----------- src/features/chat/panel/shell.dom.tsx | 29 +- .../panel/surface/composer-projection.tsx | 58 ++- .../chat/panel/surface/goal-projection.tsx | 22 +- .../panel/surface/message-stream-presenter.ts | 25 +- .../surface/message-stream-projection.ts | 77 ++-- .../pending-request-block-projection.ts | 27 ++ .../chat/panel/surface/toolbar-projection.tsx | 77 ++-- .../chat/ui/message-stream/context.ts | 9 +- .../connection/diagnostic-sections.test.ts | 23 +- .../chat/host/view-connection.test.ts | 8 +- .../chat/panel/composer-controller.test.ts | 16 +- tests/features/chat/panel/shell.test.tsx | 129 ++++--- .../surface/message-stream-presenter.test.ts | 16 +- .../chat/panel/surface/projections.test.ts | 64 ++- .../chat/panel/toolbar-archive-state.test.tsx | 14 +- .../features/chat/support/shell-read-model.ts | 10 + tests/features/chat/support/shell-state.ts | 14 - .../chat/ui/message-stream/test-helpers.tsx | 1 - tests/scripts/grit-policy.test.mjs | 16 +- 33 files changed, 738 insertions(+), 588 deletions(-) create mode 100644 src/features/chat/panel/shell-read-model.ts delete mode 100644 src/features/chat/panel/shell-state.tsx create mode 100644 src/features/chat/panel/surface/pending-request-block-projection.ts create mode 100644 tests/features/chat/support/shell-read-model.ts delete mode 100644 tests/features/chat/support/shell-state.ts diff --git a/biome.jsonc b/biome.jsonc index 1c30fd0a..9b7f0a1b 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -141,7 +141,13 @@ // Chat state and runtime ownership. { "path": "./scripts/grit/import-boundaries/no-preact-signal-imports.grit", - "includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/features/chat/panel/shell-state.tsx"] + "includes": [ + "**/src/**/*.ts", + "**/src/**/*.tsx", + "!**/src/features/chat/panel/shell-read-model.ts", + "!**/src/features/chat/panel/surface/**/*.ts", + "!**/src/features/chat/panel/surface/**/*.tsx" + ] }, { "path": "./scripts/grit/runtime/no-state-module-side-effects.grit", diff --git a/docs/design.md b/docs/design.md index ac32c30b..7707c33f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -42,7 +42,7 @@ Obsidian and app-server boundaries stay outside Preact components. External life 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. -Preact Signals are a shell-local projection adapter, not a second state system. Surface projections should read narrow shell-state contracts instead of making components or presenters depend on broad reducer slices. +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. 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/docs/development.md b/docs/development.md index 847296d2..6c79df8e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,7 @@ Generated app-server types should stay behind app-server connection and protocol Chat application workflows should receive chat-owned contracts, not root `src/app-server/` modules or direct `AppServerClient` access. Keep app-server access, connection freshness checks, vault-path injection, and payload projection in `src/features/chat/app-server/` transports or host-owned wiring. -Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the shell-state adapter. Use Preact Signals only for shell-local projection. When a surface needs fewer dependencies, add or reuse a named shell-state projection instead of importing `@preact/signals` elsewhere. +Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the panel read model adapter. Use Preact Signals only in chat panel rendering adapters such as the shell read model and surface projections. When a surface needs fewer dependencies, narrow the read model instead of passing broad reducer slices. Chat feature dependencies should flow from pure workflow and meaning code toward owned adapters and render surfaces. Lower layers must not reach into host/session wiring, panel internals, or UI implementation details. diff --git a/src/features/chat/application/connection/diagnostic-sections.ts b/src/features/chat/application/connection/diagnostic-sections.ts index 79db3986..84a9d19d 100644 --- a/src/features/chat/application/connection/diagnostic-sections.ts +++ b/src/features/chat/application/connection/diagnostic-sections.ts @@ -2,7 +2,6 @@ import { CLIENT_VERSION } from "../../../../constants"; import type { DiagnosticProbeResult, Diagnostics } from "../../../../domain/server/diagnostics"; import { type DiagnosticProbeMethod, serverIdentity, serverPlatform } from "../../../../domain/server/diagnostics"; import type { ServerInitialization } from "../../../../domain/server/initialization"; -import type { ChatState } from "../state/root-reducer"; const RUNTIME_CHECK_PROBE_METHODS: readonly DiagnosticProbeMethod[] = ["model/list", "account/rateLimits/read"]; @@ -17,29 +16,14 @@ export interface DiagnosticSection { rows: DiagnosticRow[]; } -interface ConnectionDiagnosticsInput { +export interface AppServerDiagnosticSectionsInput { connected: boolean; configuredCommand: string; initializeResponse: ServerInitialization | null; diagnostics: Diagnostics; } -export interface ConnectionDiagnosticSectionsInput { - state: Pick; - connected: boolean; - configuredCommand: string; -} - -export function connectionDiagnosticSectionsFromState(input: ConnectionDiagnosticSectionsInput): DiagnosticSection[] { - return connectionDiagnosticSections({ - connected: input.connected, - configuredCommand: input.configuredCommand, - initializeResponse: input.state.connection.initializeResponse, - diagnostics: input.state.connection.serverDiagnostics, - }); -} - -function connectionDiagnosticSections(input: ConnectionDiagnosticsInput): DiagnosticSection[] { +export function appServerDiagnosticSections(input: AppServerDiagnosticSectionsInput): DiagnosticSection[] { return [ { title: "Process", diff --git a/src/features/chat/application/pending-requests/block.ts b/src/features/chat/application/pending-requests/block.ts index ae66962a..1ab28af6 100644 --- a/src/features/chat/application/pending-requests/block.ts +++ b/src/features/chat/application/pending-requests/block.ts @@ -34,12 +34,19 @@ interface PendingRequestBlockStateSource { } export function pendingRequestBlockStateFromChatState(state: PendingRequestBlockStateSource): PendingRequestBlockState { + return pendingRequestBlockStateFromRequestState(state.requests, state.ui.disclosures.approvalDetails); +} + +export function pendingRequestBlockStateFromRequestState( + requests: ChatRequestState, + approvalDetails: ReadonlySet, +): PendingRequestBlockState { return { - approvals: state.requests.approvals, - pendingUserInputs: state.requests.pendingUserInputs, - pendingMcpElicitations: state.requests.pendingMcpElicitations, - userInputDrafts: state.requests.userInputDrafts, - mcpElicitationDrafts: state.requests.mcpElicitationDrafts, - approvalDetails: state.ui.disclosures.approvalDetails, + approvals: requests.approvals, + pendingUserInputs: requests.pendingUserInputs, + pendingMcpElicitations: requests.pendingMcpElicitations, + userInputDrafts: requests.userInputDrafts, + mcpElicitationDrafts: requests.mcpElicitationDrafts, + approvalDetails, }; } diff --git a/src/features/chat/application/state/ui-state.ts b/src/features/chat/application/state/ui-state.ts index 9a1a2d93..f3f174f6 100644 --- a/src/features/chat/application/state/ui-state.ts +++ b/src/features/chat/application/state/ui-state.ts @@ -52,7 +52,7 @@ const CHAT_DISCLOSURE_BUCKETS = [ "approvalDetails", ] as const; -export type ChatDisclosureBucket = (typeof CHAT_DISCLOSURE_BUCKETS)[number]; +type ChatDisclosureBucket = (typeof CHAT_DISCLOSURE_BUCKETS)[number]; export type ChatDisclosureUiState = Readonly>>; diff --git a/src/features/chat/host/composer-bundle.ts b/src/features/chat/host/composer-bundle.ts index 5b2bafac..24e01de7 100644 --- a/src/features/chat/host/composer-bundle.ts +++ b/src/features/chat/host/composer-bundle.ts @@ -10,7 +10,6 @@ import type { ChatMessageScrollController } from "../panel/surface/message-strea import { createVaultComposerAttachmentHandler } from "./composer-attachments.obsidian"; import type { ChatPanelEnvironment } from "./contracts"; import type { ChatPanelRuntimeSettingsActions } from "./runtime-bundle"; -import type { ChatPanelThreadLifecycle } from "./thread-bundle"; import { VaultComposerContextReferenceProvider } from "./vault-composer-context-reference-provider.obsidian"; import { VaultNoteCandidateProvider } from "./vault-note-candidate-provider.obsidian"; @@ -28,11 +27,10 @@ export interface ChatPanelComposerBundle { export function createComposerBundle( host: ChatPanelComposerHost, input: { - threadLifecycle: ChatPanelThreadLifecycle; runtimeSettings: ChatPanelRuntimeSettingsActions; }, ): ChatPanelComposerBundle { - const surface = createSessionComposerSurface(input.threadLifecycle, input.runtimeSettings); + const surface = createSessionComposerSurface(input.runtimeSettings); const controller = createSessionComposerController(host, surface, input.runtimeSettings); return { @@ -43,14 +41,8 @@ export function createComposerBundle( }; } -function createSessionComposerSurface( - threadLifecycle: ChatPanelThreadLifecycle, - runtimeSettings: ChatPanelRuntimeSettingsActions, -): ChatPanelComposerSurface { +function createSessionComposerSurface(runtimeSettings: ChatPanelRuntimeSettingsActions): ChatPanelComposerSurface { return { - thread: { - restoredPlaceholder: () => threadLifecycle.restoration.placeholder(), - }, runtime: { requestModel: (model) => runtimeSettings.requestModelFromUi(model), requestReasoningEffort: (effort) => runtimeSettings.requestReasoningEffortFromUi(effort), @@ -76,10 +68,10 @@ function createSessionComposerController( viewId: environment.obsidian.viewId, sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(), scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges(), - canInterrupt: (state) => { - return state.turnBusy && Boolean(state.activeThreadId && state.activeTurnId); + canInterrupt: (model) => { + return model.turnBusy.value && Boolean(model.activeThreadId.value && model.activeTurnId.value); }, - composerProjection: (state) => chatPanelComposerProjection(composerSurface, state), + composerProjection: (model) => chatPanelComposerProjection(composerSurface, model), currentModelForSuggestions: () => { const current = stateStore.getState(); const config = runtimeConfigOrDefault(current.connection.runtimeConfig); diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 6faeeca0..7aa5b0d2 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -152,7 +152,6 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch notifyActiveThreadIdentityChanged, }); const composer = createComposerBundle(host, { - threadLifecycle: threadLifecycle.lifecycle, runtimeSettings: runtime.settings, }); const threadActions = createThreadActionBundle(host, { diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 843d9238..7c39d622 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -137,7 +137,11 @@ export class ChatPanelSession implements ChatPanelHandle { } applyThreadRenamed(threadId: string, name: string | null): void { + const previousRestoredExplicitName = this.restoredThread()?.explicitName ?? null; this.graph.thread.identity.applyThreadRenameToActiveIdentity(threadId, name); + if (this.restoredThread()?.explicitName !== previousRestoredExplicitName) { + this.mountOrRepairShell(); + } } open(): void { diff --git a/src/features/chat/host/shell-bundle.ts b/src/features/chat/host/shell-bundle.ts index a936ceb8..1187070b 100644 --- a/src/features/chat/host/shell-bundle.ts +++ b/src/features/chat/host/shell-bundle.ts @@ -69,8 +69,10 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan navigation, }); const toolbarSurface: ChatPanelToolbarSurface = { - state: { + connection: { connected: () => connection.isConnected(), + }, + clock: { nowMs: () => Date.now(), }, settings: { diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index c626e484..29800c23 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-controller.dom"; -import type { ChatPanelComposerShellState } from "./shell-state"; +import type { ChatPanelComposerReadModel } from "./shell-read-model"; import type { ChatPanelComposerProjection } from "./surface/composer-projection"; export interface ChatComposerControllerOptions { @@ -55,8 +55,8 @@ export interface ChatComposerControllerOptions { viewId: string; sendShortcut: () => SendShortcut; scrollThreadFromComposerEdges: () => boolean; - canInterrupt: (state: ChatPanelComposerShellState) => boolean; - composerProjection: (state: ChatPanelComposerShellState) => ChatPanelComposerProjection; + canInterrupt: (model: ChatPanelComposerReadModel) => boolean; + composerProjection: (model: ChatPanelComposerReadModel) => ChatPanelComposerProjection; currentModelForSuggestions: () => string | null; threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void; togglePlan: () => void; @@ -97,16 +97,16 @@ export class ChatComposerController { return this.composer?.value.trim() ?? this.state.composer.draft.trim(); } - renderState(state: ChatPanelComposerShellState, actions: ChatComposerRenderActions): ComposerShellProps { - const projection = this.options.composerProjection(state); + renderState(model: ChatPanelComposerReadModel, actions: ChatComposerRenderActions): ComposerShellProps { + const projection = this.options.composerProjection(model); return { viewId: this.options.viewId, - draft: state.composer.draft, - busy: state.turnBusy, - canInterrupt: this.options.canInterrupt(state), + draft: model.draft.value, + busy: model.turnBusy.value, + canInterrupt: this.options.canInterrupt(model), normalPlaceholder: projection.placeholder, - suggestions: state.composer.suggestions, - selectedSuggestionIndex: state.composer.suggestSelected, + suggestions: model.suggestions.value, + selectedSuggestionIndex: model.selectedSuggestionIndex.value, pendingSelection: this.pendingSelection, onPendingSelectionApplied: this.clearPendingSelection, callbacks: this.composerCallbacks(actions), diff --git a/src/features/chat/panel/runtime-status-projection.ts b/src/features/chat/panel/runtime-status-projection.ts index b41fb140..e4b886f8 100644 --- a/src/features/chat/panel/runtime-status-projection.ts +++ b/src/features/chat/panel/runtime-status-projection.ts @@ -1,4 +1,4 @@ -import { connectionDiagnosticSectionsFromState } from "../application/connection/diagnostic-sections"; +import { appServerDiagnosticSections } from "../application/connection/diagnostic-sections"; import { toolInventoryDiagnosticSections } from "../application/connection/tool-inventory-diagnostic-sections"; import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; import type { ChatState } from "../application/state/root-reducer"; @@ -65,10 +65,12 @@ function effortStatusLines(input: ChatPanelRuntimeProjectionInput): string[] { } function connectionDiagnosticDetails(input: ChatPanelRuntimeProjectionInput): MessageStreamNoticeSection[] { - const sections = connectionDiagnosticSectionsFromState({ - state: input.state(), + const state = input.state(); + const sections = appServerDiagnosticSections({ connected: input.connected(), configuredCommand: input.configuredCommand(), + initializeResponse: state.connection.initializeResponse, + diagnostics: state.connection.serverDiagnostics, }); return noticeSectionsFromDiagnostics(sections); } diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts new file mode 100644 index 00000000..cf127601 --- /dev/null +++ b/src/features/chat/panel/shell-read-model.ts @@ -0,0 +1,364 @@ +import { batch, computed, type ReadonlySignal, type Signal, signal } from "@preact/signals"; +import { explicitThreadName } from "../../../domain/threads/model"; +import { implementPlanTargetFromState } from "../application/conversation/plan-implementation"; +import { activeTurnId, chatTurnBusy } from "../application/conversation/turn-state"; +import { messageItemsHaveThreadTurns, runtimeSnapshotForChatSlices } from "../application/runtime/snapshot"; +import { + type MessageStreamRollbackCandidate, + messageStreamActiveItems, + messageStreamItems, + messageStreamRollbackCandidateFromItems, + messageStreamStableItems, +} from "../application/state/message-stream"; +import type { ChatState } from "../application/state/root-reducer"; +import type { MessageStreamItem } from "../domain/message-stream/items"; +import { type ForkCandidate, forkCandidatesFromItems, type PlanImplementationTarget } from "../domain/message-stream/selectors"; +import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; + +export interface ChatPanelShellReadModelBinding { + readonly readModel: ChatPanelShellReadModel; + sync(nextState: ChatState): void; +} + +interface ChatPanelShellReadModel { + readonly toolbar: ChatPanelToolbarReadModel; + readonly goal: ChatPanelGoalReadModel; + readonly messageStream: ChatPanelMessageStreamReadModel; + readonly composer: ChatPanelComposerReadModel; +} + +interface ChatPanelShellSignals { + connection: Signal; + threadList: Signal; + activeThread: Signal; + runtime: Signal; + turn: Signal; + messageStream: Signal; + requests: Signal; + composer: Signal; + ui: Signal; + turnBusy: ReadonlySignal; + activeTurnId: ReadonlySignal; + activeThreadId: ReadonlySignal; + activeThreadCwd: ReadonlySignal; + activeThreadGoal: ReadonlySignal; + messageStreamItems: ReadonlySignal; + messageStreamStableItems: ReadonlySignal; + messageStreamActiveItems: ReadonlySignal; + messageStreamRollbackCandidate: ReadonlySignal; + messageStreamForkCandidates: ReadonlySignal; + messageStreamImplementPlanTarget: ReadonlySignal; + messageStreamDisclosures: ReadonlySignal; + messageStreamForkMenuItemId: 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: ChatState["activeThread"]["id"]; + 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 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; +} + +// Message stream read model + +export interface ChatPanelMessageStreamReadModel { + 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 ChatPanelMessageStreamDisclosureBucket = Exclude; + +type ChatPanelMessageStreamDisclosureState = 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 draft: ReadonlySignal; + readonly suggestions: ReadonlySignal; + readonly selectedSuggestionIndex: ReadonlySignal; + readonly activeThreadId: 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 activeThread = signal(initialState.activeThread); + const runtime = signal(initialState.runtime); + const turn = signal(initialState.turn); + const messageStream = signal(initialState.messageStream); + const requests = signal(initialState.requests); + const composer = signal(initialState.composer); + const ui = signal(initialState.ui); + const turnBusy = computed(() => chatTurnBusy({ turn: turn.value })); + const messageItems = computed(() => messageStreamItems(messageStream.value)); + const hasThreadTurns = computed(() => messageItemsHaveThreadTurns(messageItems.value)); + const activeThreadIdSignal = computed(() => activeThread.value.id); + const activeThreadCwd = computed(() => activeThread.value.cwd); + const activeThreadTokenUsage = computed(() => activeThread.value.tokenUsage); + const activeThreadGoal = computed(() => activeThread.value.goal); + const signals: ChatPanelShellSignals = { + connection, + threadList, + activeThread, + runtime, + turn, + messageStream, + requests, + composer, + ui, + turnBusy, + activeTurnId: computed(() => activeTurnId({ turn: turn.value })), + activeThreadId: activeThreadIdSignal, + activeThreadCwd, + activeThreadGoal, + messageStreamItems: messageItems, + messageStreamStableItems: computed(() => messageStreamStableItems(messageStream.value)), + messageStreamActiveItems: computed(() => messageStreamActiveItems(messageStream.value)), + messageStreamRollbackCandidate: computed(() => (turnBusy.value ? null : messageStreamRollbackCandidateFromItems(messageItems.value))), + messageStreamForkCandidates: computed(() => (turnBusy.value ? [] : forkCandidatesFromItems(messageItems.value))), + messageStreamImplementPlanTarget: computed(() => + implementPlanTargetFromState({ + activeThread: { id: activeThreadIdSignal.value }, + turn: turn.value, + runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } }, + messageStream: messageStream.value, + }), + ), + messageStreamDisclosures: createMessageStreamDisclosuresSignal(ui), + messageStreamForkMenuItemId: computed(() => ui.value.messageActionMenu.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.activeThread.value !== nextState.activeThread) signals.activeThread.value = nextState.activeThread; + if (signals.runtime.value !== nextState.runtime) signals.runtime.value = nextState.runtime; + if (signals.turn.value !== nextState.turn) signals.turn.value = nextState.turn; + if (signals.messageStream.value !== nextState.messageStream) signals.messageStream.value = nextState.messageStream; + 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 shellReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelShellReadModel { + return { + toolbar: toolbarReadModelFromSignals(signals), + goal: goalReadModelFromSignals(signals), + messageStream: messageStreamReadModelFromSignals(signals), + composer: composerReadModelFromSignals(signals), + }; +} + +function toolbarReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelToolbarReadModel { + return { + threads: computed(() => signals.threadList.value.listedThreads), + activeThreadId: signals.activeThreadId, + 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 messageStreamReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelMessageStreamReadModel { + return { + activeThreadId: signals.activeThreadId, + activeThreadCwd: signals.activeThreadCwd, + activeTurnId: signals.activeTurnId, + historyCursor: computed(() => signals.messageStream.value.historyCursor), + loadingHistory: computed(() => signals.messageStream.value.loadingHistory), + turnDiffs: computed(() => signals.messageStream.value.turnDiffs), + items: signals.messageStreamItems, + stableItems: signals.messageStreamStableItems, + activeItems: signals.messageStreamActiveItems, + requests: signals.requests, + disclosures: signals.messageStreamDisclosures, + forkMenuItemId: signals.messageStreamForkMenuItemId, + rollbackCandidate: signals.messageStreamRollbackCandidate, + forkCandidates: signals.messageStreamForkCandidates, + implementPlanTarget: signals.messageStreamImplementPlanTarget, + }; +} + +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)), + draft: computed(() => signals.composer.value.draft), + suggestions: computed(() => signals.composer.value.suggestions), + selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected), + activeThreadId: signals.activeThreadId, + 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 createMessageStreamDisclosuresSignal(ui: Signal): ReadonlySignal { + let previous: ChatPanelMessageStreamDisclosureState | null = null; + return computed(() => { + const disclosures = ui.value.disclosures; + if ( + previous && + previous.details === disclosures.details && + previous.activityGroups === disclosures.activityGroups && + previous.textDetails === disclosures.textDetails && + previous.userMessageExpanded === disclosures.userMessageExpanded && + previous.approvalDetails === disclosures.approvalDetails + ) { + return previous; + } + previous = { + details: disclosures.details, + activityGroups: disclosures.activityGroups, + textDetails: disclosures.textDetails, + userMessageExpanded: disclosures.userMessageExpanded, + approvalDetails: disclosures.approvalDetails, + }; + return previous; + }); +} diff --git a/src/features/chat/panel/shell-state.tsx b/src/features/chat/panel/shell-state.tsx deleted file mode 100644 index 140026b1..00000000 --- a/src/features/chat/panel/shell-state.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { batch, computed, type ReadonlySignal, type Signal, signal } from "@preact/signals"; -import { createContext } from "preact"; -import { useContext } from "preact/hooks"; -import { implementPlanTargetFromState } from "../application/conversation/plan-implementation"; -import { activeTurnId, chatTurnBusy } from "../application/conversation/turn-state"; -import { messageItemsHaveThreadTurns, runtimeSnapshotForChatSlices } from "../application/runtime/snapshot"; -import { - type MessageStreamRollbackCandidate, - messageStreamActiveItems, - messageStreamItems, - messageStreamRollbackCandidateFromItems, - messageStreamStableItems, -} from "../application/state/message-stream"; -import type { ChatState } from "../application/state/root-reducer"; -import type { MessageStreamItem } from "../domain/message-stream/items"; -import { type ForkCandidate, forkCandidatesFromItems, type PlanImplementationTarget } from "../domain/message-stream/selectors"; -import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; - -export interface ChatPanelShellState { - connection: Signal; - threadList: Signal; - activeThread: Signal; - runtime: Signal; - turn: Signal; - messageStream: Signal; - requests: Signal; - composer: Signal; - ui: Signal; - turnBusy: ReadonlySignal; - activeTurnId: ReadonlySignal; - activeThreadId: ReadonlySignal; - activeThreadCwd: ReadonlySignal; - activeThreadGoal: ReadonlySignal; - messageStreamItems: ReadonlySignal; - messageStreamStableItems: ReadonlySignal; - messageStreamActiveItems: ReadonlySignal; - messageStreamRollbackCandidate: ReadonlySignal; - messageStreamForkCandidates: ReadonlySignal; - messageStreamImplementPlanTarget: ReadonlySignal; - hasThreadTurns: ReadonlySignal; - goalEditor: ReadonlySignal; - goalObjectiveExpanded: ReadonlySignal; - toolbarRuntimeSnapshot: ReadonlySignal; - composerRuntimeSnapshot: ReadonlySignal; -} - -export interface ChatPanelToolbarShellState extends Pick { - readonly activeThreadId: ChatState["activeThread"]["id"]; - readonly turnBusy: boolean; - readonly runtimeSnapshot: RuntimeSnapshot; -} - -export interface ChatPanelGoalShellState { - readonly goal: ChatState["activeThread"]["goal"]; - readonly goalEditor: ChatState["ui"]["goalEditor"]; - readonly goalObjectiveExpanded: ChatState["ui"]["disclosures"]["goalObjectiveExpanded"]; -} - -export interface ChatPanelMessageStreamShellState extends Pick { - readonly activeThreadId: ChatState["activeThread"]["id"]; - readonly activeThreadCwd: ChatState["activeThread"]["cwd"]; - readonly activeTurnId: string | null; - readonly items: readonly MessageStreamItem[]; - readonly stableItems: readonly MessageStreamItem[]; - readonly activeItems: readonly MessageStreamItem[]; - readonly rollbackCandidate: MessageStreamRollbackCandidate | null; - readonly forkCandidates: readonly ForkCandidate[]; - readonly implementPlanTarget: PlanImplementationTarget | null; -} - -export interface ChatPanelComposerShellState extends Pick { - readonly activeThreadId: ChatState["activeThread"]["id"]; - readonly turnBusy: boolean; - readonly activeTurnId: string | null; - readonly runtimeSnapshot: RuntimeSnapshot; -} - -export const ChatPanelShellStateContext = createContext(null); - -export function createChatPanelShellState(initialState: ChatState): ChatPanelShellState { - const connection = signal(initialState.connection); - const threadList = signal(initialState.threadList); - const activeThread = signal(initialState.activeThread); - const runtime = signal(initialState.runtime); - const turn = signal(initialState.turn); - const messageStream = signal(initialState.messageStream); - const requests = signal(initialState.requests); - const composer = signal(initialState.composer); - const ui = signal(initialState.ui); - const turnBusy = computed(() => chatTurnBusy({ turn: turn.value })); - const messageItems = computed(() => messageStreamItems(messageStream.value)); - const hasThreadTurns = computed(() => messageItemsHaveThreadTurns(messageItems.value)); - const activeThreadIdSignal = computed(() => activeThread.value.id); - const activeThreadCwd = computed(() => activeThread.value.cwd); - const activeThreadTokenUsage = computed(() => activeThread.value.tokenUsage); - const activeThreadGoal = computed(() => activeThread.value.goal); - return { - connection, - threadList, - activeThread, - runtime, - turn, - messageStream, - requests, - composer, - ui, - turnBusy, - activeTurnId: computed(() => activeTurnId({ turn: turn.value })), - activeThreadId: activeThreadIdSignal, - activeThreadCwd, - activeThreadGoal, - messageStreamItems: messageItems, - messageStreamStableItems: computed(() => messageStreamStableItems(messageStream.value)), - messageStreamActiveItems: computed(() => messageStreamActiveItems(messageStream.value)), - messageStreamRollbackCandidate: computed(() => (turnBusy.value ? null : messageStreamRollbackCandidateFromItems(messageItems.value))), - messageStreamForkCandidates: computed(() => (turnBusy.value ? [] : forkCandidatesFromItems(messageItems.value))), - messageStreamImplementPlanTarget: computed(() => - implementPlanTargetFromState({ - activeThread: { id: activeThreadIdSignal.value }, - turn: turn.value, - runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } }, - messageStream: messageStream.value, - }), - ), - 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, - }), - ), - }; -} - -export function syncChatPanelShellState(shellState: ChatPanelShellState, nextState: ChatState): void { - batch(() => { - if (shellState.connection.value !== nextState.connection) shellState.connection.value = nextState.connection; - if (shellState.threadList.value !== nextState.threadList) shellState.threadList.value = nextState.threadList; - if (shellState.activeThread.value !== nextState.activeThread) shellState.activeThread.value = nextState.activeThread; - if (shellState.runtime.value !== nextState.runtime) shellState.runtime.value = nextState.runtime; - if (shellState.turn.value !== nextState.turn) shellState.turn.value = nextState.turn; - if (shellState.messageStream.value !== nextState.messageStream) shellState.messageStream.value = nextState.messageStream; - if (shellState.requests.value !== nextState.requests) shellState.requests.value = nextState.requests; - if (shellState.composer.value !== nextState.composer) shellState.composer.value = nextState.composer; - if (shellState.ui.value !== nextState.ui) shellState.ui.value = nextState.ui; - }); -} - -export function toolbarStateFromShellState(shellState: ChatPanelShellState): ChatPanelToolbarShellState { - return { - connection: shellState.connection.value, - threadList: shellState.threadList.value, - runtime: shellState.runtime.value, - ui: shellState.ui.value, - activeThreadId: shellState.activeThreadId.value, - turnBusy: shellState.turnBusy.value, - runtimeSnapshot: shellState.toolbarRuntimeSnapshot.value, - }; -} - -export function goalStateFromShellState(shellState: ChatPanelShellState): ChatPanelGoalShellState { - return { - goal: shellState.activeThreadGoal.value, - goalEditor: shellState.goalEditor.value, - goalObjectiveExpanded: shellState.goalObjectiveExpanded.value, - }; -} - -export function messageStreamStateFromShellState(shellState: ChatPanelShellState): ChatPanelMessageStreamShellState { - return { - messageStream: shellState.messageStream.value, - requests: shellState.requests.value, - ui: shellState.ui.value, - activeThreadId: shellState.activeThreadId.value, - activeThreadCwd: shellState.activeThreadCwd.value, - activeTurnId: shellState.activeTurnId.value, - items: shellState.messageStreamItems.value, - stableItems: shellState.messageStreamStableItems.value, - activeItems: shellState.messageStreamActiveItems.value, - rollbackCandidate: shellState.messageStreamRollbackCandidate.value, - forkCandidates: shellState.messageStreamForkCandidates.value, - implementPlanTarget: shellState.messageStreamImplementPlanTarget.value, - }; -} - -export function composerStateFromShellState(shellState: ChatPanelShellState): ChatPanelComposerShellState { - return { - connection: shellState.connection.value, - threadList: shellState.threadList.value, - runtime: shellState.runtime.value, - composer: shellState.composer.value, - activeThreadId: shellState.activeThreadId.value, - turnBusy: shellState.turnBusy.value, - activeTurnId: shellState.activeTurnId.value, - runtimeSnapshot: shellState.composerRuntimeSnapshot.value, - }; -} - -export function useChatPanelShellState(): ChatPanelShellState { - const context = useContext(ChatPanelShellStateContext); - if (!context) throw new Error("Chat panel shell state is only available inside ChatPanelShell."); - return context; -} diff --git a/src/features/chat/panel/shell.dom.tsx b/src/features/chat/panel/shell.dom.tsx index 7e30c767..6459cb4f 100644 --- a/src/features/chat/panel/shell.dom.tsx +++ b/src/features/chat/panel/shell.dom.tsx @@ -3,7 +3,7 @@ import { listenDomEvent } from "../../../shared/ui/dom-events.dom"; import { renderUiRoot, unmountUiRoot } from "../../../shared/ui/ui-root.dom"; import type { ChatStateStore } from "../application/state/store"; import type { ToolbarActions } from "../ui/toolbar"; -import { type ChatPanelShellState, ChatPanelShellStateContext, createChatPanelShellState, syncChatPanelShellState } from "./shell-state"; +import { type ChatPanelShellReadModelBinding, createChatPanelShellReadModelBinding } from "./shell-read-model"; import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerPresenter } from "./surface/composer-projection"; import { ChatPanelGoal, type ChatPanelGoalSurface } from "./surface/goal-projection"; import { ChatPanelMessageStream, type ChatPanelMessageStreamPresenter } from "./surface/message-stream-presenter"; @@ -33,7 +33,7 @@ interface ChatPanelShellMount { stateStore: ChatStateStore; unsubscribe: () => void; stopStatusBarClearanceSync: () => void; - shellState: ChatPanelShellState; + shellReadModelBinding: ChatPanelShellReadModelBinding; } const shellMounts = new WeakMap(); @@ -63,11 +63,11 @@ function createShellMount(container: HTMLElement, props: ChatPanelShellProps): C const mount: ChatPanelShellMount = { props, stateStore: props.stateStore, - shellState: createChatPanelShellState(props.stateStore.getState()), + shellReadModelBinding: createChatPanelShellReadModelBinding(props.stateStore.getState()), unsubscribe: props.stateStore.subscribe(() => { const current = shellMounts.get(container); if (!current) return; - syncChatPanelShellState(current.shellState, props.stateStore.getState()); + current.shellReadModelBinding.sync(props.stateStore.getState()); }), stopStatusBarClearanceSync: startStatusBarClearanceSync(container), }; @@ -81,7 +81,7 @@ function renderMountedShell(container: HTMLElement, mount: ChatPanelShellMount): container.replaceChildren(); } syncStatusBarClearance(container); - renderUiRoot(container, ); + renderUiRoot(container, ); } function uiRootIntact(container: HTMLElement, showToolbar: boolean): boolean { @@ -104,24 +104,29 @@ function shellRegion(container: HTMLElement, region: string): HTMLElement | null return container.querySelector(`:scope > [data-codex-panel-shell-region="${region}"]`); } -function ChatPanelShell({ showToolbar, parts, shellState }: ChatPanelShellProps & { shellState: ChatPanelShellState }): UiNode { +function ChatPanelShell({ + showToolbar, + parts, + shellReadModelBinding, +}: ChatPanelShellProps & { shellReadModelBinding: ChatPanelShellReadModelBinding }): UiNode { + const readModel = shellReadModelBinding.readModel; return ( - + <> {showToolbar ? (
- +
) : null}
- +
- +
- +
-
+ ); } diff --git a/src/features/chat/panel/surface/composer-projection.tsx b/src/features/chat/panel/surface/composer-projection.tsx index 2f5d67c2..3dd8ea41 100644 --- a/src/features/chat/panel/surface/composer-projection.tsx +++ b/src/features/chat/panel/surface/composer-projection.tsx @@ -3,19 +3,12 @@ import { h } from "preact"; import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { sortedModelMetadata } from "../../../../domain/catalog/metadata"; import { runtimeConfigOrDefault } from "../../../../domain/runtime/config"; -import { explicitThreadName } from "../../../../domain/threads/model"; 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 { ComposerShell, type ComposerShellProps } from "../../ui/composer"; -import { type ChatPanelComposerShellState, composerStateFromShellState, useChatPanelShellState } from "../shell-state"; - -interface RestoredThreadTitleSnapshot { - threadId: string; - title: string | null; - explicitName: string | null; -} +import type { ChatPanelComposerReadModel } from "../shell-read-model"; interface ChatPanelComposerContextMeterCell { text: string; @@ -54,9 +47,6 @@ export interface ChatPanelComposerProjection { } export interface ChatPanelComposerSurface { - thread: { - restoredPlaceholder: () => RestoredThreadTitleSnapshot | null; - }; runtime: { requestModel: (model: string) => Promise; requestReasoningEffort: (effort: ReasoningEffort) => Promise; @@ -64,7 +54,7 @@ export interface ChatPanelComposerSurface { } export interface ChatPanelComposerPresenter { - renderState(state: ChatPanelComposerShellState, actions: ChatPanelComposerActions): ComposerShellProps; + renderState(model: ChatPanelComposerReadModel, actions: ChatPanelComposerActions): ComposerShellProps; } export interface ChatPanelComposerActions { @@ -72,7 +62,7 @@ export interface ChatPanelComposerActions { } interface RuntimeComposerChoicesInput { - state: ChatPanelComposerShellState; + readModel: ChatPanelComposerReadModel; snapshot: RuntimeSnapshot; requestModel: (model: string) => void; requestReasoningEffort: (effort: ReasoningEffort) => void; @@ -83,27 +73,28 @@ function composerPlaceholder(threadName: string | null): string { } export function ChatPanelComposer({ + model, presenter, actions, }: { + model: ChatPanelComposerReadModel; presenter: ChatPanelComposerPresenter; actions: ChatPanelComposerActions; }): UiNode { - const state = composerStateFromShellState(useChatPanelShellState()); - return h(ComposerShell, presenter.renderState(state, actions)); + return h(ComposerShell, presenter.renderState(model, actions)); } export function chatPanelComposerProjection( surface: ChatPanelComposerSurface, - state: ChatPanelComposerShellState, + readModel: ChatPanelComposerReadModel, ): ChatPanelComposerProjection { - const snapshot = state.runtimeSnapshot; + const snapshot = readModel.runtimeSnapshot.value; return { - placeholder: composerPlaceholder(activeComposerThreadName(state, surface.thread.restoredPlaceholder())), + placeholder: composerPlaceholder(activeComposerThreadName(readModel)), meta: { - ...composerMetaViewModel(state, snapshot), + ...composerMetaViewModel(readModel, snapshot), ...runtimeComposerChoices({ - state, + readModel, snapshot, requestModel: (model) => void surface.runtime.requestModel(model), requestReasoningEffort: (effort) => void surface.runtime.requestReasoningEffort(effort), @@ -113,10 +104,11 @@ export function chatPanelComposerProjection( } function composerMetaViewModel( - state: ChatPanelComposerShellState, + readModel: ChatPanelComposerReadModel, snapshot: RuntimeSnapshot, ): Omit { - if (state.connection.phase.kind === "failed" || state.connection.phase.kind === "disconnected") { + const phase = readModel.connection.phase.value; + if (phase.kind === "failed" || phase.kind === "disconnected") { return { fatal: "Codex app-server disconnected", context: contextComposerMeter(null), @@ -129,10 +121,10 @@ function composerMetaViewModel( }; } - const config = runtimeConfigOrDefault(state.connection.runtimeConfig); + const config = runtimeConfigOrDefault(readModel.connection.runtimeConfig.value); const resolution = resolveRuntimeControls(snapshot, config); const context = contextSummary(snapshot); - const model = resolution.model.effective; + const selectedModel = resolution.model.effective; const effort = resolution.reasoningEffort.effective; const composerContext = contextComposerMeter(context?.percent ?? null); const compactEffort = effort ? compactReasoningEffortLabel(effort) : null; @@ -144,13 +136,13 @@ function composerMetaViewModel( context: composerContext, statusSummary: composerStatusSummary({ context: composerContext, - model: model ?? "default", + model: selectedModel ?? "default", effort: compactEffort, planActive, autoReviewActive: reviewActive, fastActive, }), - model: model ?? "default", + model: selectedModel ?? "default", effort: compactEffort, planActive, autoReviewActive: reviewActive, @@ -162,10 +154,10 @@ function runtimeComposerChoices(input: RuntimeComposerChoicesInput): { modelChoices: ChatPanelComposerRuntimeChoice[]; effortChoices: ChatPanelComposerRuntimeChoice[]; } { - const config = runtimeConfigOrDefault(input.state.connection.runtimeConfig); + const config = runtimeConfigOrDefault(input.readModel.connection.runtimeConfig.value); const resolution = resolveRuntimeControls(input.snapshot, config); const effectiveModel = resolution.model.effective; - const models = sortedModelMetadata(input.state.connection.availableModels); + const models = sortedModelMetadata(input.readModel.connection.availableModels.value); const modelChoices: ChatPanelComposerRuntimeChoice[] = models.slice(0, 12).map((model) => ({ label: model.model, selected: effectiveModel === model.model, @@ -216,13 +208,9 @@ function onOffLabel(active: boolean): string { return active ? "on" : "off"; } -function activeComposerThreadName(state: ChatPanelComposerShellState, restoredThread: RestoredThreadTitleSnapshot | null): string | null { - const threadId = restoredThread?.threadId ?? state.activeThreadId ?? null; - if (!threadId) return null; - const thread = state.threadList.listedThreads.find((item) => item.id === threadId); - const listedName = thread ? explicitThreadName(thread) : null; - if (listedName) return listedName; - return restoredThread?.threadId === threadId ? restoredThread.explicitName : null; +function activeComposerThreadName(model: ChatPanelComposerReadModel): string | null { + const activeThreadId = model.activeThreadId.value; + return activeThreadId ? model.activeListedThreadName.value : 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 1c4876b5..4824b7a5 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 "../../../../shared/ui/keyboard"; import type { GoalPanelActions, GoalPanelDisplayState, GoalPanelEditorState, GoalPanelOptions } from "../../ui/goal"; import { GoalPanel } from "../../ui/goal"; -import { type ChatPanelGoalShellState, goalStateFromShellState, useChatPanelShellState } from "../shell-state"; +import type { ChatPanelGoalReadModel } from "../shell-read-model"; interface ChatPanelGoalActions { saveObjective: (objective: string, tokenBudget: number | null) => Promise; @@ -20,22 +20,22 @@ export interface ChatPanelGoalSurface { actions: ChatPanelGoalActions; } -export function ChatPanelGoal({ surface }: { surface: ChatPanelGoalSurface }): UiNode { - const props = chatPanelGoalViewModel(surface, goalStateFromShellState(useChatPanelShellState())); +export function ChatPanelGoal({ model, surface }: { model: ChatPanelGoalReadModel; surface: ChatPanelGoalSurface }): UiNode { + const props = chatPanelGoalViewModel(surface, model); return h(GoalPanel, props); } interface ChatPanelGoalProjection { - goal: ChatPanelGoalShellState["goal"]; + goal: ChatPanelGoalReadModel["goal"]["value"]; goalThreadId: string | null; editor: GoalPanelEditorState; display: GoalPanelDisplayState; } -function chatPanelGoalProjection(state: ChatPanelGoalShellState): ChatPanelGoalProjection { - const goal = state.goal; +function chatPanelGoalProjection(model: ChatPanelGoalReadModel): ChatPanelGoalProjection { + const goal = model.goal.value; const goalThreadId = goal?.threadId ?? null; - const goalEditor = state.goalEditor; + const goalEditor = model.goalEditor.value; const editor = goalEditor.kind === "editing" ? { editing: true, objectiveDraft: goalEditor.objectiveDraft, tokenBudgetDraft: goalEditor.tokenBudgetDraft } @@ -45,22 +45,22 @@ function chatPanelGoalProjection(state: ChatPanelGoalShellState): ChatPanelGoalP goalThreadId, editor, display: { - objectiveExpanded: goalThreadId ? state.goalObjectiveExpanded.has(goalThreadId) : false, + objectiveExpanded: goalThreadId ? model.goalObjectiveExpanded.value.has(goalThreadId) : false, }, }; } function chatPanelGoalViewModel( surface: ChatPanelGoalSurface, - state: ChatPanelGoalShellState, + model: ChatPanelGoalReadModel, ): { - goal: ChatPanelGoalShellState["goal"]; + goal: ChatPanelGoalReadModel["goal"]["value"]; actions: GoalPanelActions; options: GoalPanelOptions; editor: GoalPanelEditorState; display: GoalPanelDisplayState; } { - const projection = chatPanelGoalProjection(state); + const projection = chatPanelGoalProjection(model); return { goal: projection.goal, actions: { diff --git a/src/features/chat/panel/surface/message-stream-presenter.ts b/src/features/chat/panel/surface/message-stream-presenter.ts index 0c6fe099..6dc98a8d 100644 --- a/src/features/chat/panel/surface/message-stream-presenter.ts +++ b/src/features/chat/panel/surface/message-stream-presenter.ts @@ -9,21 +9,26 @@ import type { ChatStateStore } from "../../application/state/store"; import type { MessageStreamScrollControllerBinding } from "../../ui/message-stream/flow-scroll.measure"; import { MarkdownMessageRenderer, renderStreamMarkdown } from "../../ui/message-stream/markdown-renderer.obsidian"; import { MessageStreamViewport, type MessageStreamViewportState } from "../../ui/message-stream/stream-blocks"; -import { type ChatPanelMessageStreamShellState, messageStreamStateFromShellState, useChatPanelShellState } from "../shell-state"; +import type { ChatPanelMessageStreamReadModel } from "../shell-read-model"; import { type ChatMessageStreamSurfaceContext, createMessageStreamSurfaceContext, - messageStreamSurfaceProjectionFromState, + messageStreamSurfaceProjectionFromModel, } from "./message-stream-projection"; export interface ChatPanelMessageStreamPresenter { - renderState(state: ChatPanelMessageStreamShellState): MessageStreamViewportState; + renderState(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState; } -export function ChatPanelMessageStream({ presenter }: { presenter: ChatPanelMessageStreamPresenter }): UiNode { - const state = messageStreamStateFromShellState(useChatPanelShellState()); +export function ChatPanelMessageStream({ + model, + presenter, +}: { + model: ChatPanelMessageStreamReadModel; + presenter: ChatPanelMessageStreamPresenter; +}): UiNode { return h(MessageStreamViewport, { - state: presenter.renderState(state), + state: presenter.renderState(model), rootAttributes: { "data-codex-panel-shell-region": "message-stream" }, }); } @@ -87,12 +92,12 @@ export class MessageStreamPresenter { this.options.state.store.dispatch(action); } - renderState(state: ChatPanelMessageStreamShellState): MessageStreamViewportState { - return this.renderStateFor(state); + renderState(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState { + return this.renderStateFor(model); } - private renderStateFor(state: ChatPanelMessageStreamShellState): MessageStreamViewportState { - const projection = messageStreamSurfaceProjectionFromState(state, this.messageStreamSurfaceContext()); + private renderStateFor(model: ChatPanelMessageStreamReadModel): MessageStreamViewportState { + const projection = messageStreamSurfaceProjectionFromModel(model, this.messageStreamSurfaceContext()); return { blocks: projection.blocks, diff --git a/src/features/chat/panel/surface/message-stream-projection.ts b/src/features/chat/panel/surface/message-stream-projection.ts index 369e5f99..59c76c0a 100644 --- a/src/features/chat/panel/surface/message-stream-projection.ts +++ b/src/features/chat/panel/surface/message-stream-projection.ts @@ -1,15 +1,14 @@ import type { TurnDiffViewState } from "../../../turn-diff/model"; -import { type PendingRequestBlockActions, pendingRequestBlockStateFromChatState } from "../../application/pending-requests/block"; +import type { PendingRequestBlockActions } from "../../application/pending-requests/block"; import type { MessageStreamRollbackCandidate } from "../../application/state/message-stream"; import type { ChatAction } from "../../application/state/root-reducer"; -import type { ChatDisclosureBucket, ChatDisclosureUiState } from "../../application/state/ui-state"; import { type ForkCandidate, messageStreamSegmentsEmpty, type PlanImplementationTarget } from "../../domain/message-stream/selectors"; -import { pendingRequestsSignature } from "../../domain/pending-requests/signatures"; import type { MessageStreamTextActionTargets } from "../../presentation/message-stream/text-view"; import { type MessageStreamViewBlock, messageStreamViewBlocks } from "../../presentation/message-stream/view-model"; -import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model"; -import type { MessageStreamContext } from "../../ui/message-stream/context"; -import type { ChatPanelMessageStreamShellState } from "../shell-state"; +import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/view-model"; +import type { MessageStreamContext, MessageStreamDisclosureBucket, MessageStreamDisclosureState } from "../../ui/message-stream/context"; +import type { ChatPanelMessageStreamReadModel } from "../shell-read-model"; +import { pendingRequestSurfaceProjectionFromState } from "./pending-request-block-projection"; interface ChatMessageStreamActions { rollbackThread: (threadId: string) => void; @@ -25,7 +24,7 @@ interface ChatMessageStreamRequests { export interface ChatMessageStreamSurfaceContext { vaultPath: string; - setDisclosureOpen: (bucket: ChatDisclosureBucket, id: string, open: boolean) => void; + setDisclosureOpen: (bucket: MessageStreamDisclosureBucket, id: string, open: boolean) => void; setForkMenuItem: (itemId: string | null) => void; loadOlderTurns: () => void; renderObsidianMarkdown: (element: HTMLElement, text: string) => void; @@ -49,7 +48,7 @@ export interface MessageStreamSurfaceContextOptions { interface MessageStreamStateProjection { activeThreadId: string | null; workspaceRoot: string; - disclosures: ChatDisclosureUiState; + disclosures: MessageStreamDisclosureState; forkMenuItemId: string | null; pendingRequests: { signature: string; snapshot: PendingRequestBlockSnapshot } | null; viewBlocks: readonly MessageStreamViewBlock[]; @@ -78,11 +77,11 @@ export function createMessageStreamSurfaceContext(options: MessageStreamSurfaceC }; } -export function messageStreamSurfaceProjectionFromState( - state: ChatPanelMessageStreamShellState, +export function messageStreamSurfaceProjectionFromModel( + model: ChatPanelMessageStreamReadModel, context: ChatMessageStreamSurfaceContext, ): MessageStreamSurfaceProjection { - const projection = messageStreamStateProjection(state, context); + const projection = messageStreamStateProjection(model, context); return { blocks: projection.viewBlocks, context: messageStreamContextFromProjection(projection, context), @@ -133,33 +132,38 @@ function messageStreamContextFromProjection( } function messageStreamStateProjection( - state: ChatPanelMessageStreamShellState, + model: ChatPanelMessageStreamReadModel, context: ChatMessageStreamSurfaceContext, ): MessageStreamStateProjection { - const workspaceRoot = state.activeThreadCwd ?? context.vaultPath; + const stableItems = model.stableItems.value; + const activeItems = model.activeItems.value; + const disclosures = model.disclosures.value; + const workspaceRoot = model.activeThreadCwd.value ?? context.vaultPath; const textActionTargetsByItemId = textActionTargetsForMessageStreamItems( - state.rollbackCandidate, - state.forkCandidates, - state.implementPlanTarget, + model.rollbackCandidate.value, + model.forkCandidates.value, + model.implementPlanTarget.value, ); - const pendingRequests = messageStreamSegmentsEmpty(state.stableItems, state.activeItems) ? null : pendingRequestBlockFromState(state); + const pendingRequests = messageStreamSegmentsEmpty(stableItems, activeItems) + ? null + : pendingRequestSurfaceProjectionFromState(model.requests.value, disclosures.approvalDetails); return { - activeThreadId: state.activeThreadId, + activeThreadId: model.activeThreadId.value, workspaceRoot, - disclosures: state.ui.disclosures, - forkMenuItemId: state.ui.messageActionMenu.forkMenuItemId, + disclosures, + forkMenuItemId: model.forkMenuItemId.value, pendingRequests, viewBlocks: messageStreamViewBlocks({ - activeThreadId: state.activeThreadId, - activeTurnId: state.activeTurnId, - historyCursor: state.messageStream.historyCursor, - loadingHistory: state.messageStream.loadingHistory, - items: state.items, - stableItems: state.stableItems, - activeItems: state.activeItems, + activeThreadId: model.activeThreadId.value, + activeTurnId: model.activeTurnId.value, + historyCursor: model.historyCursor.value, + loadingHistory: model.loadingHistory.value, + items: model.items.value, + stableItems, + activeItems, workspaceRoot, - turnDiffs: state.messageStream.turnDiffs, + turnDiffs: model.turnDiffs.value, textActionTargetsByItemId, pendingRequests, }), @@ -191,20 +195,3 @@ function patchTextActionTargets( ): void { byItemId.set(itemId, { ...byItemId.get(itemId), ...patch }); } - -function pendingRequestBlockFromState( - state: ChatPanelMessageStreamShellState, -): { signature: string; snapshot: ReturnType } | null { - const signature = pendingRequestsSignature( - state.requests.approvals, - state.requests.pendingUserInputs, - state.requests.pendingMcpElicitations, - state.requests.userInputDrafts, - state.requests.mcpElicitationDrafts, - ); - if (!signature) return null; - return { - signature, - snapshot: pendingRequestBlockSnapshotFromState(pendingRequestBlockStateFromChatState(state)), - }; -} diff --git a/src/features/chat/panel/surface/pending-request-block-projection.ts b/src/features/chat/panel/surface/pending-request-block-projection.ts new file mode 100644 index 00000000..4ad72b3a --- /dev/null +++ b/src/features/chat/panel/surface/pending-request-block-projection.ts @@ -0,0 +1,27 @@ +import { pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block"; +import type { ChatRequestState } from "../../application/pending-requests/state"; +import { pendingRequestsSignature } from "../../domain/pending-requests/signatures"; +import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model"; + +export interface PendingRequestSurfaceProjection { + readonly signature: string; + readonly snapshot: PendingRequestBlockSnapshot; +} + +export function pendingRequestSurfaceProjectionFromState( + requests: ChatRequestState, + approvalDetails: ReadonlySet, +): PendingRequestSurfaceProjection | null { + const signature = pendingRequestsSignature( + requests.approvals, + requests.pendingUserInputs, + requests.pendingMcpElicitations, + requests.userInputDrafts, + requests.mcpElicitationDrafts, + ); + if (!signature) return null; + return { + signature, + snapshot: pendingRequestBlockSnapshotFromState(pendingRequestBlockStateFromRequestState(requests, approvalDetails)), + }; +} diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index 53713f00..e216421d 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -4,16 +4,18 @@ import { h } from "preact"; import { CLIENT_VERSION } from "../../../../constants"; import type { Thread } from "../../../../domain/threads/model"; import { threadRowCoreProjection } from "../../../threads/list/row-projection"; -import { connectionDiagnosticSectionsFromState } from "../../application/connection/diagnostic-sections"; +import { appServerDiagnosticSections } from "../../application/connection/diagnostic-sections"; import { toolInventoryDiagnosticSections } from "../../application/connection/tool-inventory-diagnostic-sections"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { rateLimitSummary } from "../../presentation/runtime/status"; import { Toolbar, type ToolbarActions, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar"; -import { type ChatPanelToolbarShellState, toolbarStateFromShellState, useChatPanelShellState } from "../shell-state"; +import type { ChatPanelToolbarReadModel } from "../shell-read-model"; export interface ChatPanelToolbarSurface { - state: { + connection: { connected: () => boolean; + }; + clock: { nowMs: () => number; }; settings: { @@ -24,7 +26,7 @@ export interface ChatPanelToolbarSurface { } interface ToolbarViewModelInput { - state: ChatPanelToolbarShellState; + model: ChatPanelToolbarReadModel; snapshot: RuntimeSnapshot; connected: boolean; nowMs: number; @@ -43,28 +45,36 @@ interface ToolbarStateProjection { threads: ToolbarThreadRow[]; } -function chatPanelToolbarViewModel(surface: ChatPanelToolbarSurface, state: ChatPanelToolbarShellState) { +function chatPanelToolbarViewModel(surface: ChatPanelToolbarSurface, model: ChatPanelToolbarReadModel) { return chatPanelToolbarProjection({ - state, - snapshot: state.runtimeSnapshot, - connected: surface.state.connected(), - nowMs: surface.state.nowMs(), - turnBusy: state.turnBusy, + model, + snapshot: model.runtimeSnapshot.value, + connected: surface.connection.connected(), + nowMs: surface.clock.nowMs(), + turnBusy: model.turnBusy.value, vaultPath: surface.settings.vaultPath(), configuredCommand: surface.settings.configuredCommand(), archiveExportEnabled: surface.settings.archiveExportEnabled(), }); } -export function ChatPanelToolbar({ surface, actions }: { surface: ChatPanelToolbarSurface; actions: ToolbarActions }): UiNode { - const state = toolbarStateFromShellState(useChatPanelShellState()); - return h(Toolbar, { model: chatPanelToolbarViewModel(surface, state), actions }); +export function ChatPanelToolbar({ + model, + surface, + actions, +}: { + model: ChatPanelToolbarReadModel; + surface: ChatPanelToolbarSurface; + actions: ToolbarActions; +}): UiNode { + return h(Toolbar, { model: chatPanelToolbarViewModel(surface, model), actions }); } function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewModel { - const { state, snapshot } = input; + const { model, snapshot } = input; const projection = toolbarStateProjection(input); const limit = rateLimitSummary(snapshot, input.nowMs); + const diagnostics = model.diagnostics.value; return { newChatDisabled: projection.newChatDisabled, chatActionsOpen: projection.chatActionsOpen, @@ -75,23 +85,25 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo openPanel: projection.openPanel, threads: projection.threads, connectLabel: input.connected ? "Reconnect" : "Connect", - diagnostics: connectionDiagnosticSectionsFromState({ - state, + diagnostics: appServerDiagnosticSections({ connected: input.connected, configuredCommand: input.configuredCommand, + initializeResponse: diagnostics.initializeResponse, + diagnostics: diagnostics.serverDiagnostics, }), - toolInventory: toolInventoryDiagnosticSections(state.connection.serverDiagnostics), + toolInventory: toolInventoryDiagnosticSections(diagnostics.serverDiagnostics), }; } function runtimeDebugDetails(input: ToolbarViewModelInput): string { - const connection = input.state.connection; + const debug = input.model.debug.value; + const connection = debug.connection; return JSON.stringify( { clientVersion: CLIENT_VERSION, vaultPath: input.vaultPath, configuredCommand: input.configuredCommand, - activeThreadId: input.state.activeThreadId, + activeThreadId: debug.activeThreadId, connection: { connected: input.connected, phase: connection.phase, @@ -103,9 +115,9 @@ function runtimeDebugDetails(input: ToolbarViewModelInput): string { mcpServers: connection.serverDiagnostics.mcpServers, }, }, - runtimeConfig: connection.runtimeConfig, - runtime: input.state.runtime, - availableModels: connection.availableModels, + runtimeConfig: debug.runtimeConfig, + runtime: debug.runtime, + availableModels: debug.availableModels, }, null, 2, @@ -113,13 +125,14 @@ function runtimeDebugDetails(input: ToolbarViewModelInput): string { } function toolbarStateProjection(input: { - state: ChatPanelToolbarShellState; + model: ChatPanelToolbarReadModel; turnBusy: boolean; archiveExportEnabled: boolean; }): ToolbarStateProjection { - const historyOpen = input.state.ui.toolbarPanel === "history"; - const chatActionsOpen = input.state.ui.toolbarPanel === "chat-actions"; - const statusPanelOpen = input.state.ui.toolbarPanel === "status-panel"; + const toolbarPanel = input.model.toolbarPanel.value; + const historyOpen = toolbarPanel === "history"; + const chatActionsOpen = toolbarPanel === "chat-actions"; + const statusPanelOpen = toolbarPanel === "status-panel"; return { newChatDisabled: input.turnBusy, chatActionsOpen, @@ -127,12 +140,12 @@ function toolbarStateProjection(input: { statusPanelOpen, openPanel: historyOpen ? "history" : chatActionsOpen ? "chat-actions" : statusPanelOpen ? "status" : null, threads: toolbarThreadRows({ - threads: input.state.threadList.listedThreads, - activeThreadId: input.state.activeThreadId, + threads: input.model.threads.value, + activeThreadId: input.model.activeThreadId.value, turnBusy: input.turnBusy, - archiveConfirmThreadId: input.state.ui.archiveConfirmThreadId, + archiveConfirmThreadId: input.model.archiveConfirmThreadId.value, archiveExportEnabled: input.archiveExportEnabled, - renameState: input.state.ui.rename, + renameState: input.model.rename.value, }), }; } @@ -143,7 +156,7 @@ function toolbarThreadRows(input: { turnBusy: boolean; archiveConfirmThreadId: string | null; archiveExportEnabled: boolean; - renameState: ChatPanelToolbarShellState["ui"]["rename"]; + renameState: ChatPanelToolbarReadModel["rename"]["value"]; }): ToolbarThreadRow[] { return input.threads.map((thread) => { const threadId = thread.id; @@ -166,7 +179,7 @@ function toolbarThreadRows(input: { }); } -function toolbarActiveRenameState(renameState: ChatPanelToolbarShellState["ui"]["rename"], threadId: string) { +function toolbarActiveRenameState(renameState: ChatPanelToolbarReadModel["rename"]["value"], threadId: string) { if (renameState.kind === "idle" || renameState.threadId !== threadId) return undefined; return renameState; } diff --git a/src/features/chat/ui/message-stream/context.ts b/src/features/chat/ui/message-stream/context.ts index 69e9c06a..42d668e4 100644 --- a/src/features/chat/ui/message-stream/context.ts +++ b/src/features/chat/ui/message-stream/context.ts @@ -4,20 +4,13 @@ import type { PlanImplementationTarget } from "../../domain/message-stream/selec import type { MessageStreamForkTarget } from "../../presentation/message-stream/text-view"; import type { PendingRequestBlockSnapshot } from "../../presentation/pending-requests/view-model"; -type MessageStreamDisclosureBucket = - | "details" - | "activityGroups" - | "textDetails" - | "userMessageExpanded" - | "goalObjectiveExpanded" - | "approvalDetails"; +export type MessageStreamDisclosureBucket = "details" | "activityGroups" | "textDetails" | "userMessageExpanded" | "approvalDetails"; export interface MessageStreamDisclosureState { details: ReadonlySet; activityGroups: ReadonlySet; textDetails: ReadonlySet; userMessageExpanded: ReadonlySet; - goalObjectiveExpanded: ReadonlySet; approvalDetails: ReadonlySet; } diff --git a/tests/features/chat/application/connection/diagnostic-sections.test.ts b/tests/features/chat/application/connection/diagnostic-sections.test.ts index 7d035971..8f2c9c12 100644 --- a/tests/features/chat/application/connection/diagnostic-sections.test.ts +++ b/tests/features/chat/application/connection/diagnostic-sections.test.ts @@ -9,9 +9,8 @@ import { upsertMcpServerStatusDiagnostics, } from "../../../../../src/domain/server/diagnostics"; import type { ToolInventorySnapshot } from "../../../../../src/domain/server/tool-inventory"; -import { connectionDiagnosticSectionsFromState } from "../../../../../src/features/chat/application/connection/diagnostic-sections"; +import { appServerDiagnosticSections } from "../../../../../src/features/chat/application/connection/diagnostic-sections"; import { toolInventoryDiagnosticSections } from "../../../../../src/features/chat/application/connection/tool-inventory-diagnostic-sections"; -import { chatStateFixture } from "../../support/state"; function diagnosticsWithToolInventory(inventory: ToolInventorySnapshot) { let diagnostics = createServerDiagnostics(); @@ -45,20 +44,16 @@ describe("connection diagnostics", () => { message: null, }); - const sections = connectionDiagnosticSectionsFromState({ + const sections = appServerDiagnosticSections({ connected: true, configuredCommand: "/opt/homebrew/bin/codex", - state: chatStateFixture({ - connection: { - initializeResponse: { - userAgent: "codex-cli/0.130.0", - codexHome: "/Users/showhey/.codex", - platformFamily: "unix", - platformOs: "macos", - }, - serverDiagnostics: diagnostics, - }, - }), + initializeResponse: { + userAgent: "codex-cli/0.130.0", + codexHome: "/Users/showhey/.codex", + platformFamily: "unix", + platformOs: "macos", + }, + diagnostics, }); const rows = sections.flatMap((section) => section.rows); diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index 701da5b8..bc039cfb 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -590,7 +590,7 @@ describe("CodexChatView connection lifecycle", () => { expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" }); }); - it("does not use restored thread title as a composer name before an explicit rename notification", async () => { + it("does not use restored thread identity as a composer name before the thread becomes active", async () => { const host = chatHost(); const view = await chatView({ host }); @@ -600,10 +600,14 @@ describe("CodexChatView connection lifecycle", () => { expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task..."); host.threadCatalog.apply({ type: "active-list-snapshot-received", threads: [panelThread({ id: "thread-1", name: "Explicit name" })] }); + await waitForAsyncWork(() => { + expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task..."); + }); + view.surface.applyThreadRenamed("thread-1", "Explicit name"); await waitForAsyncWork(() => { - expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Explicit name”..."); + expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task..."); }); }); diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 3b52c40b..a1bc6c2f 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -12,11 +12,11 @@ import type { NoteCandidateProvider } from "../../../../src/features/chat/applic import type { ChatStateStore } from "../../../../src/features/chat/application/state/store"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import { ChatComposerController, type ChatComposerRenderActions } from "../../../../src/features/chat/panel/composer-controller"; -import type { ChatPanelComposerShellState } from "../../../../src/features/chat/panel/shell-state"; +import type { ChatPanelComposerReadModel } from "../../../../src/features/chat/panel/shell-read-model"; import { ComposerShell } from "../../../../src/features/chat/ui/composer"; import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/ui/ui-root.dom"; import { installObsidianDomShims } from "../../../support/dom"; -import { composerShellStateFromChatState } from "../support/shell-state"; +import { composerReadModelFromChatState } from "../support/shell-read-model"; installObsidianDomShims(); @@ -26,15 +26,15 @@ function renderComposerController( stateStore: ChatStateStore, actions: ChatComposerRenderActions = { submit: vi.fn() }, ): void { - renderUiRoot(parent, h(ComposerShell, controller.renderState(composerShellStateFromChatState(stateStore.getState()), actions))); + renderUiRoot(parent, h(ComposerShell, controller.renderState(composerReadModelFromChatState(stateStore.getState()), actions))); } describe("ChatComposerController", () => { it("derives composer placeholder and meta from the projection", () => { const stateStore = createChatStateStore(); - const projection = vi.fn((state: ChatPanelComposerShellState) => ({ - placeholder: `Projected ${state.composer.draft || "empty"}`, - meta: defaultComposerProjection(state).meta, + const projection = vi.fn((model: ChatPanelComposerReadModel) => ({ + placeholder: `Projected ${model.draft.value || "empty"}`, + meta: defaultComposerProjection(model).meta, })); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), @@ -55,7 +55,7 @@ describe("ChatComposerController", () => { onHeightChange: vi.fn(), }); - const props = controller.renderState(composerShellStateFromChatState(stateStore.getState()), { submit: vi.fn() }); + const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() }); expect(props.normalPlaceholder).toBe("Projected empty"); expect(props.meta.statusSummary).toBe( @@ -873,7 +873,7 @@ function skill(name: string): SkillMetadata { }; } -function defaultComposerProjection(_state: ChatPanelComposerShellState) { +function defaultComposerProjection(_model: ChatPanelComposerReadModel) { return { placeholder: "Ask Codex to work on this task...", meta: { diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 68a4483d..855b45b0 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -4,12 +4,10 @@ import { act } from "preact/test-utils"; import { describe, expect, it, vi } from "vitest"; import type { ComposerContextReferenceProvider } from "../../../../src/features/chat/application/composer/context-references"; import type { NoteCandidateProvider } from "../../../../src/features/chat/application/composer/note-context"; -import { messageStreamItems } from "../../../../src/features/chat/application/state/message-stream"; 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 { ChatPanelComposerShellState } from "../../../../src/features/chat/panel/shell-state"; -import type { ChatPanelComposerSurface } from "../../../../src/features/chat/panel/surface/composer-projection"; +import type { ChatPanelComposerReadModel } from "../../../../src/features/chat/panel/shell-read-model"; import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection"; import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection"; import { messageStreamViewBlocks } from "../../../../src/features/chat/presentation/message-stream/view-model"; @@ -266,6 +264,12 @@ describe("ChatPanelShell", () => { }); expect(renderMessageStreamState).not.toHaveBeenCalled(); + await act(async () => { + store.dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: "thread", open: true }); + await settleShellEffects(); + }); + expect(renderMessageStreamState).not.toHaveBeenCalled(); + await act(async () => { store.dispatch({ type: "active-thread/cwd-set", cwd: "/workspace" }); await settleShellEffects(); @@ -414,59 +418,65 @@ function shellParts( }, goal: surface.goal, messageStream: { - renderState: (state) => ({ - blocks: messageStreamViewBlocks({ - activeThreadId: state.activeThreadId, - activeTurnId: null, - historyCursor: state.messageStream.historyCursor, - loadingHistory: state.messageStream.loadingHistory, - items: messageStreamItems(state.messageStream), - }), - context: testMessageStreamContext, - scrollController: noOpMessageStreamScrollController, - }), + renderState: (model) => { + void model.activeThreadCwd.value; + return { + blocks: messageStreamViewBlocks({ + activeThreadId: model.activeThreadId.value, + activeTurnId: null, + historyCursor: model.historyCursor.value, + loadingHistory: model.loadingHistory.value, + items: model.items.value, + }), + context: testMessageStreamContext, + scrollController: noOpMessageStreamScrollController, + }; + }, }, composer: { presenter: { - renderState: (state) => ({ - viewId: "view", - draft: state.composer.draft, - busy: false, - canInterrupt: false, - normalPlaceholder: "Ask Codex to work on this task...", - suggestions: [], - selectedSuggestionIndex: 0, - callbacks: { - onInput: vi.fn(), - onUpdateSuggestions: vi.fn(), - onKeydown: vi.fn(), - onSendOrInterrupt: vi.fn(), - onHeightChange: vi.fn(), - onSuggestionHover: vi.fn(), - onSuggestionInsert: vi.fn(), - }, - meta: { - fatal: null, - context: { - cells: [ - { text: "⣀", placeholder: true }, - { text: "⣀", placeholder: true }, - { text: "⣀", placeholder: true }, - { text: "⣀", placeholder: true }, - ], - percent: "--%", + renderState: (model) => { + void model.runtimeSnapshot.value; + return { + viewId: "view", + draft: model.draft.value, + busy: false, + canInterrupt: false, + normalPlaceholder: "Ask Codex to work on this task...", + suggestions: [], + selectedSuggestionIndex: 0, + callbacks: { + onInput: vi.fn(), + onUpdateSuggestions: vi.fn(), + onKeydown: vi.fn(), + onSendOrInterrupt: vi.fn(), + onHeightChange: vi.fn(), + onSuggestionHover: vi.fn(), + onSuggestionInsert: vi.fn(), }, - statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default", - model: "default", - effort: null, - planActive: false, - autoReviewActive: false, - fastActive: false, - modelChoices: [], - effortChoices: [], - }, - onComposer: () => undefined, - }), + meta: { + fatal: null, + context: { + cells: [ + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + ], + percent: "--%", + }, + statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default", + model: "default", + effort: null, + planActive: false, + autoReviewActive: false, + fastActive: false, + modelChoices: [], + effortChoices: [], + }, + onComposer: () => undefined, + }; + }, }, actions: { submit: vi.fn(), @@ -505,7 +515,7 @@ function contextProvider( }; } -function composerProjectionFixture(_state: ChatPanelComposerShellState) { +function composerProjectionFixture(_model: ChatPanelComposerReadModel) { return { placeholder: "Ask Codex to work on this task...", meta: { @@ -538,7 +548,6 @@ const testMessageStreamContext: MessageStreamContext = { activityGroups: new Set(), textDetails: new Set(), userMessageExpanded: new Set(), - goalObjectiveExpanded: new Set(), approvalDetails: new Set(), }, forkMenuItemId: null, @@ -549,12 +558,13 @@ const testMessageStreamContext: MessageStreamContext = { function surfaceFixture(options: { toolbarConnected?: () => boolean; goalSendShortcut?: () => "enter" | "mod-enter" } = {}): { toolbar: ChatPanelToolbarSurface; goal: ChatPanelGoalSurface; - composer: ChatPanelComposerSurface; } { return { toolbar: { - state: { + connection: { connected: options.toolbarConnected ?? (() => false), + }, + clock: { nowMs: () => 0, }, settings: { @@ -575,13 +585,6 @@ function surfaceFixture(options: { toolbarConnected?: () => boolean; goalSendSho closeEditor: () => undefined, }, }, - composer: { - thread: { restoredPlaceholder: () => null }, - runtime: { - requestModel: async () => undefined, - requestReasoningEffort: async () => undefined, - }, - }, }; } diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index 259a64e3..663a88d3 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -9,7 +9,7 @@ import { type ChatStateStore, createChatStateStore } from "../../../../../src/fe import { MessageStreamPresenter } from "../../../../../src/features/chat/panel/surface/message-stream-presenter"; import { type ChatMessageStreamSurfaceContext, - messageStreamSurfaceProjectionFromState, + messageStreamSurfaceProjectionFromModel, } from "../../../../../src/features/chat/panel/surface/message-stream-projection"; import { type ChatMessageScrollController, @@ -21,7 +21,7 @@ import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-roo import { notices } from "../../../../mocks/obsidian"; import { installObsidianDomShims } from "../../../../support/dom"; import { withChatStateMessageStreamItems } from "../../support/message-stream"; -import { messageStreamShellStateFromChatState } from "../../support/shell-state"; +import { messageStreamReadModelFromChatState } from "../../support/shell-read-model"; import { chatStateFixture, chatStateWith } from "../../support/state"; import { installMessageViewportMetrics, pendingApproval } from "../../ui/message-stream/test-helpers"; @@ -30,7 +30,7 @@ const ESTIMATED_MESSAGE_BLOCK_HEIGHT = 96; installObsidianDomShims(); function renderMessageStreamPresenter(parent: HTMLElement, presenter: MessageStreamPresenter, state: ChatState): void { - renderUiRoot(parent, h(MessageStreamViewport, { state: presenter.renderState(messageStreamShellStateFromChatState(state)) })); + renderUiRoot(parent, h(MessageStreamViewport, { state: presenter.renderState(messageStreamReadModelFromChatState(state)) })); } describe("MessageStreamPresenter scroll pinning", () => { @@ -50,8 +50,8 @@ describe("MessageStreamPresenter scroll pinning", () => { approvalsReviewer: null, }); - const projection = messageStreamSurfaceProjectionFromState( - messageStreamShellStateFromChatState(store.getState()), + const projection = messageStreamSurfaceProjectionFromModel( + messageStreamReadModelFromChatState(store.getState()), messageStreamSurfaceContext({ vaultPath: "/vault", dispatch: (action) => { @@ -76,7 +76,7 @@ describe("MessageStreamPresenter scroll pinning", () => { }, }); - const context = messageStreamSurfaceProjectionFromState(messageStreamShellStateFromChatState(store.getState()), surfaceContext).context; + const context = messageStreamSurfaceProjectionFromModel(messageStreamReadModelFromChatState(store.getState()), surfaceContext).context; if (!context.onDisclosureToggle) throw new Error("Expected message stream disclosure action"); context.onDisclosureToggle("textDetails", "message:details", true); @@ -87,8 +87,8 @@ describe("MessageStreamPresenter scroll pinning", () => { let state = chatStateFixture(); state = withChatStateMessageStreamItems(state, [{ id: "system", kind: "system", role: "system", text: "Waiting for approval." }]); state = chatStateWith(state, { requests: { approvals: [pendingApproval()] } }); - const projection = messageStreamSurfaceProjectionFromState( - messageStreamShellStateFromChatState(state), + const projection = messageStreamSurfaceProjectionFromModel( + messageStreamReadModelFromChatState(state), messageStreamSurfaceContext({ vaultPath: "/vault", dispatch: () => undefined, diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 26a00f04..b6ec992e 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -9,7 +9,7 @@ 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 { ChatPanelShellStateContext, createChatPanelShellState } from "../../../../../src/features/chat/panel/shell-state"; +import { createChatPanelShellReadModelBinding } from "../../../../../src/features/chat/panel/shell-read-model"; import type { ChatPanelComposerSurface } 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"; @@ -18,7 +18,7 @@ import type { ToolbarActions } from "../../../../../src/features/chat/ui/toolbar import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root.dom"; import { installObsidianDomShims } from "../../../../support/dom"; import { withChatStateMessageStreamItems } from "../../support/message-stream"; -import { composerShellStateFromChatState } from "../../support/shell-state"; +import { composerReadModelFromChatState } from "../../support/shell-read-model"; import { chatStateFixture, chatStateWith } from "../../support/state"; installObsidianDomShims(); @@ -39,9 +39,12 @@ describe("chat panel surface projections", () => { }); state = chatStateWith(state, { connection: { serverDiagnostics: createServerDiagnostics() } }); - const parent = renderWithShellState( - state, - h(ChatPanelToolbar, { surface: toolbarSurfaceFixture({ archiveExportEnabled: true }), actions: toolbarActionsFixture() }), + const parent = renderWithShellReadModel(state, (readModel) => + h(ChatPanelToolbar, { + model: readModel.toolbar, + surface: toolbarSurfaceFixture({ archiveExportEnabled: true }), + actions: toolbarActionsFixture(), + }), ); expect(parent.querySelector('[data-codex-panel-toolbar-panel="history"]')).not.toBeNull(); @@ -69,9 +72,12 @@ describe("chat panel surface projections", () => { state = chatStateWith(state, { runtime: { pending: { model: { kind: "set", value: "gpt-debug" } } } }); const copyDebugDetails = vi.fn<(details: string) => void>(); - const parent = renderWithShellState( - state, - h(ChatPanelToolbar, { surface: toolbarSurfaceFixture(), actions: toolbarActionsFixture({ copyDebugDetails }) }), + const parent = renderWithShellReadModel(state, (readModel) => + h(ChatPanelToolbar, { + model: readModel.toolbar, + surface: toolbarSurfaceFixture(), + actions: toolbarActionsFixture({ copyDebugDetails }), + }), ); parent.querySelectorAll(".codex-panel__status-panel-item")[2]?.click(); @@ -292,13 +298,13 @@ describe("chat panel surface projections", () => { }, } satisfies ChatPanelGoalSurface; - const parent = renderWithShellState(state, h(ChatPanelGoal, { surface })); + const parent = renderWithShellReadModel(state, (readModel) => h(ChatPanelGoal, { model: readModel.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 = renderWithShellState(state, h(ChatPanelGoal, { surface })); + const resumeParent = renderWithShellReadModel(state, (readModel) => h(ChatPanelGoal, { model: readModel.goal, surface })); clickLabeledButton(resumeParent, "Resume goal"); expect(statuses).toEqual([ @@ -339,23 +345,6 @@ describe("chat panel surface projections", () => { expect(composerProjectionFromState(composerSurfaceFixture(), chatStateFixture()).placeholder).toBe("Ask Codex to work on this task..."); }); - it("uses restored thread names in the composer projection", () => { - let state = chatStateFixture(); - state = chatStateWith(state, { activeThread: { id: "thread-1" } }); - state = chatStateWith(state, { threadList: { listedThreads: [threadFixture("thread-1", null)] } }); - - expect( - composerProjectionFromState( - composerSurfaceFixture({ - thread: { - restoredPlaceholder: () => ({ threadId: "thread-1", title: "Restored", explicitName: "Restored" }), - }, - }), - state, - ).placeholder, - ).toBe("Ask Codex to work on “Restored”..."); - }); - it("projects goal editor and disclosure state before action wiring", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { goal: goalFixture("thread-1") } }); @@ -364,21 +353,26 @@ describe("chat panel surface projections", () => { }); state = chatStateWith(state, { ui: { disclosures: { goalObjectiveExpanded: new Set(["thread-1"]) } } }); - const parent = renderWithShellState(state, h(ChatPanelGoal, { surface: goalSurfaceFixture() })); + const parent = renderWithShellReadModel(state, (readModel) => + h(ChatPanelGoal, { model: readModel.goal, surface: goalSurfaceFixture() }), + ); expect(parent.querySelector(".codex-panel__goal-objective-input")?.value).toBe("Draft goal"); unmountUiRoot(parent); }); }); -function renderWithShellState(state: ChatState, node: ComponentChild): HTMLElement { +function renderWithShellReadModel( + state: ChatState, + node: (readModel: ReturnType["readModel"]) => ComponentChild, +): HTMLElement { const parent = document.createElement("div"); - renderUiRoot(parent, h(ChatPanelShellStateContext.Provider, { value: createChatPanelShellState(state) }, node)); + renderUiRoot(parent, node(createChatPanelShellReadModelBinding(state).readModel)); return parent; } function composerProjectionFromState(surface: ChatPanelComposerSurface, state: ChatState) { - return chatPanelComposerProjection(surface, composerShellStateFromChatState(state)); + return chatPanelComposerProjection(surface, composerReadModelFromChatState(state)); } function clickLabeledButton(parent: HTMLElement, label: string): void { @@ -389,8 +383,10 @@ function clickLabeledButton(parent: HTMLElement, label: string): void { function toolbarSurfaceFixture(overrides: { archiveExportEnabled?: boolean } = {}) { return { - state: { + connection: { connected: () => true, + }, + clock: { nowMs: () => 0, }, settings: { @@ -456,10 +452,6 @@ function goalSurfaceFixture(): ChatPanelGoalSurface { function composerSurfaceFixture(overrides: Partial = {}): ChatPanelComposerSurface { return { - thread: { - restoredPlaceholder: () => null, - ...overrides.thread, - }, runtime: { requestModel: async () => undefined, requestReasoningEffort: async () => undefined, diff --git a/tests/features/chat/panel/toolbar-archive-state.test.tsx b/tests/features/chat/panel/toolbar-archive-state.test.tsx index a6091624..b8ff3de8 100644 --- a/tests/features/chat/panel/toolbar-archive-state.test.tsx +++ b/tests/features/chat/panel/toolbar-archive-state.test.tsx @@ -7,7 +7,6 @@ import type { Thread } from "../../../../src/domain/threads/model"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import type { ThreadManagementActions } from "../../../../src/features/chat/application/threads/thread-management-actions"; import { type ChatPanelShellParts, renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/panel/shell.dom"; -import type { ChatPanelComposerSurface } from "../../../../src/features/chat/panel/surface/composer-projection"; import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection"; import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection"; import { createToolbarPanelActions, type ToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions"; @@ -56,8 +55,10 @@ describe("chat toolbar archive confirmation state", () => { function toolbarSurface(_store: ReturnType, _toolbarActions: ToolbarPanelActions): ChatPanelToolbarSurface { return { - state: { + connection: { connected: () => false, + }, + clock: { nowMs: () => 0, }, settings: { @@ -170,7 +171,6 @@ function surfaceFixture( ): { toolbar: ChatPanelToolbarSurface; goal: ChatPanelGoalSurface; - composer: ChatPanelComposerSurface; } { return { toolbar: toolbarSurface(store, toolbarActions), @@ -186,13 +186,6 @@ function surfaceFixture( closeEditor: () => undefined, }, }, - composer: { - thread: { restoredPlaceholder: () => null }, - runtime: { - requestModel: async () => undefined, - requestReasoningEffort: async () => undefined, - }, - }, }; } @@ -209,7 +202,6 @@ const testMessageStreamContext: MessageStreamContext = { activityGroups: new Set(), textDetails: new Set(), userMessageExpanded: new Set(), - goalObjectiveExpanded: new Set(), approvalDetails: new Set(), }, forkMenuItemId: null, diff --git a/tests/features/chat/support/shell-read-model.ts b/tests/features/chat/support/shell-read-model.ts new file mode 100644 index 00000000..29fc073b --- /dev/null +++ b/tests/features/chat/support/shell-read-model.ts @@ -0,0 +1,10 @@ +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 messageStreamReadModelFromChatState(state: ChatState) { + return createChatPanelShellReadModelBinding(state).readModel.messageStream; +} diff --git a/tests/features/chat/support/shell-state.ts b/tests/features/chat/support/shell-state.ts deleted file mode 100644 index e308aad7..00000000 --- a/tests/features/chat/support/shell-state.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer"; -import { - composerStateFromShellState, - createChatPanelShellState, - messageStreamStateFromShellState, -} from "../../../../src/features/chat/panel/shell-state"; - -export function composerShellStateFromChatState(state: ChatState): ReturnType { - return composerStateFromShellState(createChatPanelShellState(state)); -} - -export function messageStreamShellStateFromChatState(state: ChatState): ReturnType { - return messageStreamStateFromShellState(createChatPanelShellState(state)); -} diff --git a/tests/features/chat/ui/message-stream/test-helpers.tsx b/tests/features/chat/ui/message-stream/test-helpers.tsx index 7023a1ee..2f0aa4bb 100644 --- a/tests/features/chat/ui/message-stream/test-helpers.tsx +++ b/tests/features/chat/ui/message-stream/test-helpers.tsx @@ -108,7 +108,6 @@ export function testDisclosures( activityGroups: new Set(overrides.activityGroups), textDetails: new Set(overrides.textDetails), userMessageExpanded: new Set(overrides.userMessageExpanded), - goalObjectiveExpanded: new Set(overrides.goalObjectiveExpanded), approvalDetails: new Set(overrides.approvalDetails), }; } diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 665462fb..53b239ac 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -184,7 +184,15 @@ runner = new Runner(() => runner.stop()); "no-ui-root-imports.grit", ]); await writeFile( - path.join(cwd, "src/features/chat/panel/shell-state.tsx"), + path.join(cwd, "src/features/chat/panel/shell-read-model.ts"), + ` +import { signal } from "@preact/signals"; + +export const status = signal("idle"); +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/panel/surface/signal-surface.tsx"), ` import { signal } from "@preact/signals"; @@ -374,7 +382,8 @@ export function timestamp(): number { const report = biomeLint( [ - "src/features/chat/panel/shell-state.tsx", + "src/features/chat/panel/shell-read-model.ts", + "src/features/chat/panel/surface/signal-surface.tsx", "src/shared/ui/components.tsx", "src/shared/ui/signal-escapes.tsx", "src/features/chat/ui/dom-bridge-escape.tsx", @@ -390,7 +399,8 @@ export function timestamp(): number { cwd, ); - expect(pluginDiagnostics(report, "src/features/chat/panel/shell-state.tsx")).toEqual([]); + expect(pluginDiagnostics(report, "src/features/chat/panel/shell-read-model.ts")).toEqual([]); + expect(pluginDiagnostics(report, "src/features/chat/panel/surface/signal-surface.tsx")).toEqual([]); expect(pluginMessages(report, "src/shared/ui/components.tsx")).toEqual(["Do not import @preact/signals from this module."]); expect(pluginMessages(report, "src/shared/ui/signal-escapes.tsx")).toEqual([ "Do not import @preact/signals from this module.",