Use derived signals for chat composer state

This commit is contained in:
murashit 2026-06-22 18:23:40 +09:00
parent d6214bbdc1
commit 4d4aae59db
11 changed files with 146 additions and 46 deletions

View file

@ -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 = [

View file

@ -8,10 +8,14 @@ interface RuntimeSnapshotInput {
activeThread: Pick<ChatState["activeThread"], "id" | "tokenUsage">;
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,
});
}

View file

@ -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: () => {

View file

@ -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,

View file

@ -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<ChatState["connection"]>;
@ -14,6 +17,10 @@ export interface ChatPanelShellState {
requests: Signal<ChatState["requests"]>;
composer: Signal<ChatState["composer"]>;
ui: Signal<ChatState["ui"]>;
turnBusy: ReadonlySignal<boolean>;
activeTurnId: ReadonlySignal<string | null>;
hasThreadTurns: ReadonlySignal<boolean>;
composerRuntimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
}
export type ChatPanelToolbarShellState = Pick<ChatState, "connection" | "threadList" | "activeThread" | "runtime" | "turn" | "ui">;
@ -22,24 +29,51 @@ export type ChatPanelGoalShellState = Pick<ChatState, "activeThread" | "ui">;
export type ChatPanelMessageStreamShellState = Pick<ChatState, "activeThread" | "runtime" | "turn" | "messageStream" | "requests" | "ui">;
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<ChatPanelShellState | null>(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,
};
}

View file

@ -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: {

View file

@ -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<typeof runtimeSnapshotForChatSlices>[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,
});
}

View file

@ -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(

View file

@ -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");

View file

@ -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<HTMLButtonElement>("button")).find((item) => item.getAttribute("aria-label") === label);
if (!button) throw new Error(`Expected button: ${label}`);

View file

@ -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),
};
}