diff --git a/eslint.config.mjs b/eslint.config.mjs index 11a46f96..43b85581 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,7 +49,7 @@ const removedChatStateEscapeHatchRestrictions = [ const chatSignalAdapterRestrictions = [ { selector: "ImportDeclaration[source.value='@preact/signals']", - message: "Keep chat signals in panel/shell-state.tsx as a reducer-to-Preact notification adapter.", + message: "Keep chat signals in panel/shell-state.tsx as a reducer-to-Preact notification and derived projection adapter.", }, ]; const chatDomainLayerRestrictions = [ diff --git a/src/features/chat/application/runtime/snapshot.ts b/src/features/chat/application/runtime/snapshot.ts index 243b4d14..de17cf27 100644 --- a/src/features/chat/application/runtime/snapshot.ts +++ b/src/features/chat/application/runtime/snapshot.ts @@ -8,10 +8,14 @@ interface RuntimeSnapshotInput { activeThread: Pick; runtime: ChatState["runtime"]; rateLimit: ChatState["connection"]["rateLimit"]; - items: readonly MessageStreamItem[]; + hasThreadTurns: boolean; availableModels: ChatState["connection"]["availableModels"]; } +export function messageItemsHaveThreadTurns(items: readonly MessageStreamItem[]): boolean { + return items.some((item) => item.turnId); +} + export function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): RuntimeSnapshot { return { runtimeConfig: input.runtimeConfig, @@ -30,7 +34,7 @@ export function runtimeSnapshotForChatSlices(input: RuntimeSnapshotInput): Runti requestedFastMode: input.runtime.requestedFastMode, tokenUsage: input.activeThread.tokenUsage, rateLimit: input.rateLimit, - hasThreadTurns: input.items.some((item) => item.turnId), + hasThreadTurns: input.hasThreadTurns, availableModels: input.availableModels, }; } @@ -41,7 +45,7 @@ export function runtimeSnapshotForChatState(state: ChatState): RuntimeSnapshot { activeThread: state.activeThread, runtime: state.runtime, rateLimit: state.connection.rateLimit, - items: messageStreamItems(state.messageStream), + hasThreadTurns: messageItemsHaveThreadTurns(messageStreamItems(state.messageStream)), availableModels: state.connection.availableModels, }); } diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index efe9c2bb..83175b38 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -18,7 +18,7 @@ import type { ComposerSubmitActions } from "../application/conversation/composer import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions"; import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions"; -import { activeTurnId, chatTurnBusy, type ChatAction, type ChatConnectionPhase } from "../application/state/root-reducer"; +import { chatTurnBusy, type ChatAction, type ChatConnectionPhase } from "../application/state/root-reducer"; import { messageStreamItems } from "../application/state/message-stream"; import type { ChatStateStore } from "../application/state/store"; import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../application/lifecycle"; @@ -694,7 +694,7 @@ function createSessionComposerController( sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut, scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges, canInterrupt: (state) => { - return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state)); + return state.turnBusy && Boolean(state.activeThread.id && state.activeTurnId); }, composerProjection: (state) => chatPanelComposerProjection(composerSurface, state), currentModelForSuggestions: () => { diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index 0be9b38f..b13f7700 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -1,7 +1,7 @@ import type { CodexInput } from "../../../domain/chat/input"; import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard"; import { textareaCursorAtVisualBoundary } from "../../../shared/ui/textarea-caret"; -import { chatTurnBusy, type ChatAction, type ChatState } from "../application/state/root-reducer"; +import type { ChatAction, ChatState } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { ComposerShellProps } from "../ui/composer"; import { syncComposerHeight, type ComposerCallbacks } from "../ui/composer"; @@ -64,7 +64,7 @@ export class ChatComposerController { return { viewId: this.options.viewId, draft: state.composer.draft, - busy: chatTurnBusy(state), + busy: state.turnBusy, canInterrupt: this.options.canInterrupt(state), normalPlaceholder: projection.placeholder, suggestions: state.composer.suggestions, diff --git a/src/features/chat/panel/shell-state.tsx b/src/features/chat/panel/shell-state.tsx index a952662d..a06cf1f5 100644 --- a/src/features/chat/panel/shell-state.tsx +++ b/src/features/chat/panel/shell-state.tsx @@ -1,8 +1,11 @@ import { createContext } from "preact"; import { useContext } from "preact/hooks"; -import { batch, signal, type Signal } from "@preact/signals"; +import { batch, computed, signal, type ReadonlySignal, type Signal } from "@preact/signals"; -import type { ChatState } from "../application/state/root-reducer"; +import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; +import { messageItemsHaveThreadTurns, runtimeSnapshotForChatSlices } from "../application/runtime/snapshot"; +import { activeTurnId, chatTurnBusy, type ChatState } from "../application/state/root-reducer"; +import { messageStreamItems } from "../application/state/message-stream"; export interface ChatPanelShellState { connection: Signal; @@ -14,6 +17,10 @@ export interface ChatPanelShellState { requests: Signal; composer: Signal; ui: Signal; + turnBusy: ReadonlySignal; + activeTurnId: ReadonlySignal; + hasThreadTurns: ReadonlySignal; + composerRuntimeSnapshot: ReadonlySignal; } export type ChatPanelToolbarShellState = Pick; @@ -22,24 +29,51 @@ export type ChatPanelGoalShellState = Pick; export type ChatPanelMessageStreamShellState = Pick; -export type ChatPanelComposerShellState = Pick< +export interface ChatPanelComposerShellState extends Pick< ChatState, - "connection" | "threadList" | "activeThread" | "runtime" | "turn" | "messageStream" | "composer" ->; + "connection" | "threadList" | "activeThread" | "runtime" | "composer" +> { + 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 hasThreadTurns = computed(() => messageItemsHaveThreadTurns(messageStreamItems(messageStream.value))); return { - connection: signal(initialState.connection), - threadList: signal(initialState.threadList), - activeThread: signal(initialState.activeThread), - runtime: signal(initialState.runtime), - turn: signal(initialState.turn), - messageStream: signal(initialState.messageStream), - requests: signal(initialState.requests), - composer: signal(initialState.composer), - ui: signal(initialState.ui), + connection, + threadList, + activeThread, + runtime, + turn, + messageStream, + requests, + composer, + ui, + turnBusy: computed(() => chatTurnBusy({ turn: turn.value })), + activeTurnId: computed(() => activeTurnId({ turn: turn.value })), + hasThreadTurns, + composerRuntimeSnapshot: computed(() => + runtimeSnapshotForChatSlices({ + runtimeConfig: connection.value.runtimeConfig, + activeThread: activeThread.value, + runtime: runtime.value, + rateLimit: connection.value.rateLimit, + hasThreadTurns: hasThreadTurns.value, + availableModels: connection.value.availableModels, + }), + ), }; } @@ -92,9 +126,10 @@ export function composerStateFromShellState(shellState: ChatPanelShellState): Ch threadList: shellState.threadList.value, activeThread: shellState.activeThread.value, runtime: shellState.runtime.value, - turn: shellState.turn.value, - messageStream: shellState.messageStream.value, composer: shellState.composer.value, + turnBusy: shellState.turnBusy.value, + activeTurnId: shellState.activeTurnId.value, + runtimeSnapshot: shellState.composerRuntimeSnapshot.value, }; } diff --git a/src/features/chat/panel/surface/composer-projection.tsx b/src/features/chat/panel/surface/composer-projection.tsx index cb605615..72aa5000 100644 --- a/src/features/chat/panel/surface/composer-projection.tsx +++ b/src/features/chat/panel/surface/composer-projection.tsx @@ -17,7 +17,6 @@ import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { ComposerShell, type ComposerShellProps } from "../../ui/composer"; import { composerStateFromShellState, useChatPanelShellState, type ChatPanelComposerShellState } from "../shell-state"; import { explicitThreadName } from "../../../../domain/threads/model"; -import { runtimeSnapshotForShellState } from "./runtime-snapshot"; interface RestoredThreadTitleSnapshot { threadId: string; @@ -105,7 +104,7 @@ export function chatPanelComposerProjection( surface: ChatPanelComposerSurface, state: ChatPanelComposerShellState, ): ChatPanelComposerProjection { - const snapshot = runtimeSnapshotForShellState(state); + const snapshot = state.runtimeSnapshot; return { placeholder: composerPlaceholder(activeComposerThreadName(state, surface.thread.restoredPlaceholder())), meta: { diff --git a/src/features/chat/panel/surface/runtime-snapshot.ts b/src/features/chat/panel/surface/runtime-snapshot.ts index 9f7f88bf..1b76e569 100644 --- a/src/features/chat/panel/surface/runtime-snapshot.ts +++ b/src/features/chat/panel/surface/runtime-snapshot.ts @@ -1,15 +1,10 @@ -import { messageStreamItems } from "../../application/state/message-stream"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { runtimeSnapshotForChatSlices } from "../../application/runtime/snapshot"; -import type { ChatPanelComposerShellState, ChatPanelToolbarShellState } from "../shell-state"; - -export function runtimeSnapshotForShellState(state: ChatPanelComposerShellState): RuntimeSnapshot { - return runtimeSnapshotForSurfaceState(state, messageStreamItems(state.messageStream)); -} +import type { ChatPanelToolbarShellState } from "../shell-state"; export function runtimeSnapshotForToolbarShellState(state: ChatPanelToolbarShellState): RuntimeSnapshot { // Toolbar shell state intentionally avoids subscribing to messageStream. - return runtimeSnapshotForSurfaceState(state, []); + return runtimeSnapshotForSurfaceState(state, false); } function runtimeSnapshotForSurfaceState( @@ -18,14 +13,14 @@ function runtimeSnapshotForSurfaceState( readonly activeThread: ChatPanelToolbarShellState["activeThread"]; readonly runtime: ChatPanelToolbarShellState["runtime"]; }, - items: Parameters[0]["items"], + hasThreadTurns: boolean, ): RuntimeSnapshot { return runtimeSnapshotForChatSlices({ runtimeConfig: state.connection.runtimeConfig, activeThread: state.activeThread, runtime: state.runtime, rateLimit: state.connection.rateLimit, - items, + hasThreadTurns, availableModels: state.connection.availableModels, }); } diff --git a/tests/features/chat/conversation/composer/controller.test.ts b/tests/features/chat/conversation/composer/controller.test.ts index eb685525..e54b26e1 100644 --- a/tests/features/chat/conversation/composer/controller.test.ts +++ b/tests/features/chat/conversation/composer/controller.test.ts @@ -12,6 +12,7 @@ import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata"; import { installObsidianDomShims } from "../../../../support/dom"; import type { ChatPanelComposerShellState } from "../../../../../src/features/chat/panel/shell-state"; import type { NoteCandidateProvider } from "../../../../../src/features/chat/application/composer/note-context"; +import { composerShellStateFromChatState } from "../../support/shell-state"; installObsidianDomShims(); @@ -21,7 +22,7 @@ function renderComposerController( stateStore: ChatStateStore, actions: ChatComposerRenderActions = { submit: vi.fn() }, ): void { - renderUiRoot(parent, h(ComposerShell, controller.renderState(stateStore.getState(), actions))); + renderUiRoot(parent, h(ComposerShell, controller.renderState(composerShellStateFromChatState(stateStore.getState()), actions))); } describe("ChatComposerController", () => { @@ -49,7 +50,7 @@ describe("ChatComposerController", () => { onHeightChange: vi.fn(), }); - const props = controller.renderState(stateStore.getState(), { submit: vi.fn() }); + const props = controller.renderState(composerShellStateFromChatState(stateStore.getState()), { submit: vi.fn() }); expect(props.normalPlaceholder).toBe("Projected empty"); expect(props.meta.statusSummary).toBe( diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index d902a42d..37571d2f 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -68,6 +68,51 @@ describe("ChatPanelShell", () => { }); }); + it("keeps composer rendering off message stream updates until turn presence changes", async () => { + const store = createChatStateStore(); + const container = document.createElement("div"); + document.body.appendChild(container); + const parts = shellParts(); + const renderComposerState = vi.fn(parts.composer.renderer.renderState.bind(parts.composer.renderer)); + parts.composer.renderer.renderState = renderComposerState; + + await act(async () => { + renderChatPanelShell(container, { ...shellProps(store), parts }); + await settleShellEffects(); + }); + renderComposerState.mockClear(); + + await act(async () => { + store.dispatch({ + type: "message-stream/system-item-added", + item: { id: "system-1", kind: "system", role: "system", text: "Status only." }, + }); + await settleShellEffects(); + }); + expect(renderComposerState).not.toHaveBeenCalled(); + + await act(async () => { + store.dispatch({ + type: "message-stream/item-added", + item: { + id: "assistant-1", + turnId: "turn-1", + kind: "message", + messageKind: "assistantResponse", + messageState: "completed", + role: "assistant", + text: "Turn response.", + }, + }); + await settleShellEffects(); + }); + expect(renderComposerState).toHaveBeenCalledTimes(1); + + await act(async () => { + unmountChatPanelShell(container); + }); + }); + it("removes and restores the toolbar from shell props without replacing the body regions", async () => { const store = createChatStateStore(); const container = document.createElement("div"); diff --git a/tests/features/chat/panel/surface/projection.test.ts b/tests/features/chat/panel/surface/projection.test.ts index a1644eb2..4e33038c 100644 --- a/tests/features/chat/panel/surface/projection.test.ts +++ b/tests/features/chat/panel/surface/projection.test.ts @@ -22,6 +22,7 @@ import { ChatPanelShellStateContext, createChatPanelShellState } from "../../../ import type { ToolbarActions } from "../../../../../src/features/chat/ui/toolbar"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root"; import { installObsidianDomShims } from "../../../../support/dom"; +import { composerShellStateFromChatState } from "../../support/shell-state"; installObsidianDomShims(); @@ -110,7 +111,7 @@ describe("chat panel surface projections", () => { }, }); - expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({ + expect(composerProjectionFromState(composerSurfaceFixture(), state).meta).toMatchObject({ fatal: null, context: { cells: [ @@ -145,7 +146,7 @@ describe("chat panel surface projections", () => { ]); state = chatStateWith(state, { connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }) } }); - expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({ + expect(composerProjectionFromState(composerSurfaceFixture(), state).meta).toMatchObject({ fatal: null, context: { cells: [ @@ -175,7 +176,7 @@ describe("chat panel surface projections", () => { }, }); - expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({ + expect(composerProjectionFromState(composerSurfaceFixture(), state).meta).toMatchObject({ context: { cells: [ { text: "⣀", placeholder: true }, @@ -193,7 +194,7 @@ describe("chat panel surface projections", () => { let state = chatStateFixture(); state = chatStateWith(state, { connection: { phase: { kind: "failed", message: "Connection failed." } } }); - expect(chatPanelComposerProjection(composerSurfaceFixture(), state).meta).toMatchObject({ + expect(composerProjectionFromState(composerSurfaceFixture(), state).meta).toMatchObject({ fatal: "Codex app-server disconnected", context: { cells: [ @@ -263,7 +264,7 @@ describe("chat panel surface projections", () => { const selectedModels: string[] = []; const selectedEfforts: string[] = []; - const choices = chatPanelComposerProjection( + const choices = composerProjectionFromState( composerSurfaceFixture({ runtime: { requestModel: async (model) => { @@ -344,7 +345,7 @@ describe("chat panel surface projections", () => { }); state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-5.5")] } }); - const projection = chatPanelComposerProjection(composerSurfaceFixture(), state); + const projection = composerProjectionFromState(composerSurfaceFixture(), state); expect(projection).toMatchObject({ placeholder: "Ask Codex to work on this task...", @@ -362,8 +363,8 @@ describe("chat panel surface projections", () => { activeState = chatStateWith(activeState, { activeThread: { id: "thread-1" } }); activeState = chatStateWith(activeState, { threadList: { listedThreads: [threadFixture("thread-1", "Active")] } }); - expect(chatPanelComposerProjection(composerSurfaceFixture(), activeState).placeholder).toBe("Ask Codex to work on “Active”..."); - expect(chatPanelComposerProjection(composerSurfaceFixture(), chatStateFixture()).placeholder).toBe("Ask Codex to work on this task..."); + expect(composerProjectionFromState(composerSurfaceFixture(), activeState).placeholder).toBe("Ask Codex to work on “Active”..."); + expect(composerProjectionFromState(composerSurfaceFixture(), chatStateFixture()).placeholder).toBe("Ask Codex to work on this task..."); }); it("uses restored thread names in the composer projection", () => { @@ -372,7 +373,7 @@ describe("chat panel surface projections", () => { state = chatStateWith(state, { threadList: { listedThreads: [threadFixture("thread-1", null)] } }); expect( - chatPanelComposerProjection( + composerProjectionFromState( composerSurfaceFixture({ thread: { restoredPlaceholder: () => ({ threadId: "thread-1", title: "Restored", explicitName: "Restored" }), @@ -404,6 +405,10 @@ function renderWithShellState(state: ChatState, node: ComponentChild): HTMLEleme return parent; } +function composerProjectionFromState(surface: ChatPanelComposerSurface, state: ChatState) { + return chatPanelComposerProjection(surface, composerShellStateFromChatState(state)); +} + function clickLabeledButton(parent: HTMLElement, label: string): void { const button = Array.from(parent.querySelectorAll("button")).find((item) => item.getAttribute("aria-label") === label); if (!button) throw new Error(`Expected button: ${label}`); diff --git a/tests/features/chat/support/shell-state.ts b/tests/features/chat/support/shell-state.ts new file mode 100644 index 00000000..8898b928 --- /dev/null +++ b/tests/features/chat/support/shell-state.ts @@ -0,0 +1,16 @@ +import { runtimeSnapshotForChatState } from "../../../../src/features/chat/application/runtime/snapshot"; +import { activeTurnId, chatTurnBusy, type ChatState } from "../../../../src/features/chat/application/state/root-reducer"; +import type { ChatPanelComposerShellState } from "../../../../src/features/chat/panel/shell-state"; + +export function composerShellStateFromChatState(state: ChatState): ChatPanelComposerShellState { + return { + connection: state.connection, + threadList: state.threadList, + activeThread: state.activeThread, + runtime: state.runtime, + composer: state.composer, + turnBusy: chatTurnBusy(state), + activeTurnId: activeTurnId(state), + runtimeSnapshot: runtimeSnapshotForChatState(state), + }; +}