refactor(chat): replace signal mirror with selectors

This commit is contained in:
murashit 2026-07-16 21:44:32 +09:00
parent 359bf1c21e
commit a97f80e25e
26 changed files with 778 additions and 702 deletions

View file

@ -131,32 +131,6 @@
},
// Chat state and runtime ownership.
{
"path": "./scripts/grit/import-boundaries/no-preact-signal-imports.grit",
"includes": [
"**/src/**/*.{ts,tsx}",
"!**/src/features/chat/panel/shell-read-model.ts",
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
]
},
{
"path": "./scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit",
"includes": [
"**/src/features/chat/**/*.{ts,tsx}",
"!**/src/features/chat/panel/shell-read-model.ts",
"!**/src/features/chat/panel/shell.dom.tsx",
"!**/src/features/chat/panel/composer-controller.ts",
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
]
},
{
"path": "./scripts/grit/import-boundaries/no-chat-signal-type-references.grit",
"includes": [
"**/src/features/chat/**/*.{ts,tsx}",
"!**/src/features/chat/panel/shell-read-model.ts",
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
]
},
{
"path": "./scripts/grit/runtime/no-state-module-side-effects.grit",
"includes": ["**/src/features/chat/application/state/**/*.ts"]

View file

@ -44,11 +44,11 @@ Runtime UI composition is Preact-owned. Preact components should render the pane
Obsidian and app-server boundaries stay outside Preact components. External lifecycles, app-server connections, editor/workspace APIs, and rendering bridges belong in boundary modules.
Chat-visible state belongs in the chat state store and named reducer actions. Signals and components may project that state, but they should not become parallel sources of truth.
Chat-visible state belongs in the chat state store and named reducer actions. Components should project that state through narrow selector-backed snapshots rather than mirror it into another reactive store.
Each shared app-server resource should have one authoritative query record. Derived metadata may report status but must not duplicate its snapshot.
Preact Signals are a chat panel rendering adapter, not a second state system. Panel surfaces may read narrow signal-backed read models, but application workflows, domain code, presentation helpers, and pure UI components must not depend on broad reducer slices or reactive state primitives.
Each top-level panel region should subscribe to a narrow selector-backed store projection. Region selectors should retain stable snapshots across unrelated state changes so streaming updates do not rerender the toolbar, goal, or composer unnecessarily.
Imperative DOM bridges are allowed when an external API, host lifecycle, hit-test, focus/selection operation, or measurement problem requires an `HTMLElement`. They should remain narrow boundary adapters, not a second UI composition system inside Preact-owned surfaces.

27
package-lock.json generated
View file

@ -9,7 +9,6 @@
"version": "5.0.1",
"license": "Apache-2.0",
"dependencies": {
"@preact/signals": "^2.9.3",
"@tanstack/query-core": "^5.101.2",
"defuddle": "^0.19.1",
"micromark": "^4.0.2",
@ -2358,32 +2357,6 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@preact/signals": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.9.3.tgz",
"integrity": "sha512-4g3L/dYgUt0PPE6mnYsgCP/3DEhmAYTQxjzwfSPnXQ7Lj1WPBBEY83F48X0RcNEP3ycr57T8Hcpg4PVU5VM7pg==",
"license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.14.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact": ">= 10.25.0 || >=11.0.0-0"
}
},
"node_modules/@preact/signals-core": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz",
"integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",

View file

@ -58,7 +58,6 @@
"vitest": "^4.1.10"
},
"dependencies": {
"@preact/signals": "^2.9.3",
"@tanstack/query-core": "^5.101.2",
"defuddle": "^0.19.1",
"micromark": "^4.0.2",

View file

@ -1,17 +0,0 @@
language js
private pattern js_module_reference() {
or {
JsImport(),
JsImportCallExpression()
}
}
private pattern chat_shell_read_model_source() {
r"^[\"'](?:(?:\.\./)+(?:panel/)?shell-read-model|(?:\./)?shell-read-model|src/features/chat/panel/shell-read-model)(?:\.ts)?[\"']$"
}
js_module_reference() as $stmt where {
$stmt <: contains `$source` where { $source <: chat_shell_read_model_source() },
register_diagnostic(span=$stmt, message="Import chat panel signal read models only from the shell and panel surface rendering adapters.", severity="error")
}

View file

@ -1,8 +0,0 @@
language js
or {
`ReadonlySignal<$type>` as $stmt,
`Signal<$type>` as $stmt
} where {
register_diagnostic(span=$stmt, message="Keep Preact signal types inside the chat panel read-model and surface rendering adapter layer.", severity="error")
}

View file

@ -1,13 +0,0 @@
language js
private pattern js_module_reference() {
or {
JsImport(),
JsImportCallExpression()
}
}
js_module_reference() as $stmt where {
$stmt <: contains `$source` where { $source <: r"^[\"']@preact/signals[\"']$" },
register_diagnostic(span=$stmt, message="Do not import @preact/signals from this module.", severity="error")
}

View file

@ -40,7 +40,7 @@ export function createChatComposerController(
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges(),
canInterrupt: (model) => {
return model.turnBusy.value && Boolean(model.activeThreadId.value && model.activeTurnId.value);
return model.turnBusy && Boolean(model.activeThreadId && model.activeTurnId);
},
composerProjection: (model) =>
chatPanelComposerProjection(model, {

View file

@ -0,0 +1,75 @@
import { useLayoutEffect, useReducer, useRef } from "preact/hooks";
import type { ChatState } from "../application/state/root-reducer";
import type { ChatStateStore } from "../application/state/store";
type ChatSelector<Selection> = (state: ChatState) => Selection;
type SelectionEquality<Selection> = (left: Selection, right: Selection) => boolean;
interface SelectionCache<Selection> {
store: ChatStateStore;
selector: ChatSelector<Selection>;
state: ChatState;
selection: Selection;
}
export function useChatSelector<Selection extends object>(
store: ChatStateStore,
selector: ChatSelector<Selection>,
equality: SelectionEquality<Selection> = shallowEqual,
): Selection {
const cacheRef = useRef<SelectionCache<Selection> | null>(null);
const [, rerender] = useReducer((version: number) => version + 1, 0);
const state = store.getState();
const previous = cacheRef.current;
const selection = selectionFor(previous, store, selector, state, equality);
cacheRef.current = { store, selector, state, selection };
useLayoutEffect(() => {
let active = true;
const update = (): void => {
if (!active) return;
const current = cacheRef.current;
if (!current || current.store !== store || current.selector !== selector) return;
const nextState = store.getState();
if (nextState === current.state) return;
const nextSelection = selector(nextState);
if (equality(current.selection, nextSelection)) {
cacheRef.current = { store, selector, state: nextState, selection: current.selection };
return;
}
cacheRef.current = { store, selector, state: nextState, selection: nextSelection };
rerender(undefined);
};
const unsubscribe = store.subscribe(update);
update();
return () => {
active = false;
unsubscribe();
};
}, [store, selector, equality]);
return selection;
}
function selectionFor<Selection extends object>(
previous: SelectionCache<Selection> | null,
store: ChatStateStore,
selector: ChatSelector<Selection>,
state: ChatState,
equality: SelectionEquality<Selection>,
): Selection {
if (previous?.store === store && previous.selector === selector) {
if (previous.state === state) return previous.selection;
const next = selector(state);
return equality(previous.selection, next) ? previous.selection : next;
}
return selector(state);
}
function shallowEqual(left: object, right: object): boolean {
if (left === right) return true;
const leftKeys = Object.keys(left);
if (leftKeys.length !== Object.keys(right).length) return false;
return leftKeys.every((key) => Object.is(left[key as keyof typeof left], right[key as keyof typeof right]));
}

View file

@ -43,7 +43,7 @@ import {
composerTransferHasFiles,
focusComposer,
} from "./composer-element.dom";
import type { ChatPanelComposerReadModel } from "./shell-read-model";
import type { ChatPanelComposerModel } from "./shell-selectors";
import type { ChatPanelComposerProjection } from "./surface/composer-projection";
export interface ChatComposerControllerOptions {
@ -56,8 +56,8 @@ export interface ChatComposerControllerOptions {
referenceActiveNoteOnSend: () => boolean;
sendShortcut: () => SendShortcut;
scrollThreadFromComposerEdges: () => boolean;
canInterrupt: (model: ChatPanelComposerReadModel) => boolean;
composerProjection: (model: ChatPanelComposerReadModel) => ChatPanelComposerProjection;
canInterrupt: (model: ChatPanelComposerModel) => boolean;
composerProjection: (model: ChatPanelComposerModel) => ChatPanelComposerProjection;
currentModelForSuggestions: () => string | null;
threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void;
togglePlan: () => void;
@ -100,18 +100,18 @@ export class ChatComposerController {
return this.draft.trim();
}
renderState(model: ChatPanelComposerReadModel, actions: ChatComposerRenderActions): ComposerShellProps {
renderState(model: ChatPanelComposerModel, actions: ChatComposerRenderActions): ComposerShellProps {
const projection = this.options.composerProjection(model);
return {
viewId: this.options.viewId,
draft: model.draft.value,
busy: model.turnBusy.value,
draft: model.draft,
busy: model.turnBusy,
canInterrupt: this.options.canInterrupt(model),
submissionDisabled: model.activeThreadSubagent.value || model.webSubmissionPending.value,
webSubmissionCancellable: model.webSubmissionCancellable.value,
submissionDisabled: model.activeThreadSubagent || model.webSubmissionPending,
webSubmissionCancellable: model.webSubmissionCancellable,
normalPlaceholder: projection.placeholder,
suggestions: model.suggestions.value,
selectedSuggestionIndex: model.selectedSuggestionIndex.value,
suggestions: model.suggestions,
selectedSuggestionIndex: model.selectedSuggestionIndex,
pendingSelection: this.pendingSelection,
onPendingSelectionApplied: this.clearPendingSelection,
callbacks: this.composerCallbacks(actions),

View file

@ -1,414 +0,0 @@
import { batch, computed, type ReadonlySignal, type Signal, signal } from "@preact/signals";
import { explicitThreadName } from "../../../domain/threads/model";
import { runtimeSnapshotForChatSlices, threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot";
import type { ChatActiveThreadState, ChatState } from "../application/state/root-reducer";
import {
type ThreadStreamRollbackCandidate,
threadStreamActiveItems,
threadStreamItems,
threadStreamRollbackCandidateFromItems,
threadStreamStableItems,
} from "../application/state/thread-stream";
import { implementPlanTarget } from "../application/turns/plan-implementation";
import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state";
import type { RuntimeSnapshot } from "../domain/runtime/snapshot";
import type { ThreadStreamItem } from "../domain/thread-stream/items";
import { type ForkCandidate, forkCandidatesFromItems, type PlanImplementationTarget } from "../domain/thread-stream/selectors";
export interface ChatPanelShellReadModelBinding {
readonly readModel: ChatPanelShellReadModel;
sync(nextState: ChatState): void;
}
interface ChatPanelShellReadModel {
readonly toolbar: ChatPanelToolbarReadModel;
readonly goal: ChatPanelGoalReadModel;
readonly threadStream: ChatPanelThreadStreamReadModel;
readonly composer: ChatPanelComposerReadModel;
}
interface ChatPanelShellSignals {
connection: Signal<ChatState["connection"]>;
threadList: Signal<ChatState["threadList"]>;
panelThread: Signal<ChatState["panelThread"]>;
activeThread: ReadonlySignal<ChatActiveThreadState | null>;
runtime: Signal<ChatState["runtime"]>;
turn: Signal<ChatState["turn"]>;
threadStream: Signal<ChatState["threadStream"]>;
pendingSubmission: Signal<ChatState["pendingSubmission"]>;
requests: Signal<ChatState["requests"]>;
composer: Signal<ChatState["composer"]>;
ui: Signal<ChatState["ui"]>;
turnBusy: ReadonlySignal<boolean>;
activeTurnId: ReadonlySignal<string | null>;
activeThreadId: ReadonlySignal<string | null>;
activeThreadCwd: ReadonlySignal<ChatActiveThreadState["cwd"]>;
activeThreadGoal: ReadonlySignal<ChatActiveThreadState["goal"]>;
threadStreamItems: ReadonlySignal<readonly ThreadStreamItem[]>;
threadStreamStableItems: ReadonlySignal<readonly ThreadStreamItem[]>;
threadStreamActiveItems: ReadonlySignal<readonly ThreadStreamItem[]>;
threadStreamRollbackCandidate: ReadonlySignal<ThreadStreamRollbackCandidate | null>;
threadStreamForkCandidates: ReadonlySignal<readonly ForkCandidate[]>;
threadStreamImplementPlanTarget: ReadonlySignal<PlanImplementationTarget | null>;
webSubmissionPending: ReadonlySignal<boolean>;
webSubmissionCancellable: ReadonlySignal<boolean>;
threadStreamDisclosures: ReadonlySignal<ChatPanelThreadStreamDisclosureState>;
threadStreamForkMenuItemId: ReadonlySignal<ChatState["ui"]["threadStreamActionMenu"]["forkMenuItemId"]>;
hasThreadTurns: ReadonlySignal<boolean>;
goalEditor: ReadonlySignal<ChatState["ui"]["goalEditor"]>;
goalObjectiveExpanded: ReadonlySignal<ChatState["ui"]["disclosures"]["goalObjectiveExpanded"]>;
toolbarRuntimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
composerRuntimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
}
// Toolbar read model
type ChatPanelToolbarDebugConnectionState = Pick<
ChatState["connection"],
"phase" | "statusText" | "initializeResponse" | "rateLimit" | "serverDiagnostics"
>;
interface ChatPanelToolbarDiagnosticState {
readonly initializeResponse: ChatState["connection"]["initializeResponse"];
readonly serverDiagnostics: ChatState["connection"]["serverDiagnostics"];
}
interface ChatPanelToolbarDebugState {
readonly activeThreadId: string | null;
readonly connection: ChatPanelToolbarDebugConnectionState;
readonly runtimeConfig: ChatState["connection"]["runtimeConfig"];
readonly runtime: ChatState["runtime"];
readonly availableModels: ChatState["connection"]["availableModels"];
}
export interface ChatPanelToolbarReadModel {
readonly threads: ReadonlySignal<ChatState["threadList"]["listedThreads"]>;
readonly activeThreadId: ReadonlySignal<string | null>;
readonly activeThreadSubagent: ReadonlySignal<boolean>;
readonly turnBusy: ReadonlySignal<boolean>;
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
readonly toolbarPanel: ReadonlySignal<ChatState["ui"]["toolbarPanel"]>;
readonly archiveConfirmThreadId: ReadonlySignal<ChatState["ui"]["archiveConfirmThreadId"]>;
readonly rename: ReadonlySignal<ChatState["ui"]["rename"]>;
readonly diagnostics: ReadonlySignal<ChatPanelToolbarDiagnosticState>;
readonly debug: ReadonlySignal<ChatPanelToolbarDebugState>;
}
// Goal read model
export interface ChatPanelGoalReadModel {
readonly goal: ReadonlySignal<ChatActiveThreadState["goal"]>;
readonly goalEditor: ReadonlySignal<ChatState["ui"]["goalEditor"]>;
readonly goalObjectiveExpanded: ReadonlySignal<ChatState["ui"]["disclosures"]["goalObjectiveExpanded"]>;
}
// Thread stream read model
export interface ChatPanelThreadStreamReadModel {
readonly activeThreadId: ReadonlySignal<string | null>;
readonly activeThreadCwd: ReadonlySignal<ChatActiveThreadState["cwd"]>;
readonly activeTurnId: ReadonlySignal<string | null>;
readonly historyCursor: ReadonlySignal<ChatState["threadStream"]["historyCursor"]>;
readonly loadingHistory: ReadonlySignal<ChatState["threadStream"]["loadingHistory"]>;
readonly turnDiffs: ReadonlySignal<ChatState["threadStream"]["turnDiffs"]>;
readonly items: ReadonlySignal<readonly ThreadStreamItem[]>;
readonly stableItems: ReadonlySignal<readonly ThreadStreamItem[]>;
readonly activeItems: ReadonlySignal<readonly ThreadStreamItem[]>;
readonly requests: ReadonlySignal<ChatState["requests"]>;
readonly disclosures: ReadonlySignal<ChatPanelThreadStreamDisclosureState>;
readonly forkMenuItemId: ReadonlySignal<ChatState["ui"]["threadStreamActionMenu"]["forkMenuItemId"]>;
readonly rollbackCandidate: ReadonlySignal<ThreadStreamRollbackCandidate | null>;
readonly forkCandidates: ReadonlySignal<readonly ForkCandidate[]>;
readonly implementPlanTarget: ReadonlySignal<PlanImplementationTarget | null>;
}
type ChatPanelThreadStreamDisclosureBucket = Exclude<keyof ChatState["ui"]["disclosures"], "goalObjectiveExpanded">;
type ChatPanelThreadStreamDisclosureState = Pick<ChatState["ui"]["disclosures"], ChatPanelThreadStreamDisclosureBucket>;
// Composer read model
type ChatPanelComposerConnectionState = Pick<ChatState["connection"], "phase" | "runtimeConfig" | "availableModels">;
export interface ChatPanelComposerReadModel {
readonly connection: {
readonly phase: ReadonlySignal<ChatPanelComposerConnectionState["phase"]>;
readonly runtimeConfig: ReadonlySignal<ChatPanelComposerConnectionState["runtimeConfig"]>;
readonly availableModels: ReadonlySignal<ChatPanelComposerConnectionState["availableModels"]>;
};
readonly activeListedThreadName: ReadonlySignal<string | null>;
readonly sideChatActive: ReadonlySignal<boolean>;
readonly sideChatSourceTitle: ReadonlySignal<string | null>;
readonly draft: ReadonlySignal<ChatState["composer"]["draft"]>;
readonly suggestions: ReadonlySignal<ChatState["composer"]["suggestions"]>;
readonly selectedSuggestionIndex: ReadonlySignal<ChatState["composer"]["suggestSelected"]>;
readonly activeThreadId: ReadonlySignal<string | null>;
readonly activeThreadSubagent: ReadonlySignal<boolean>;
readonly webSubmissionPending: ReadonlySignal<boolean>;
readonly webSubmissionCancellable: ReadonlySignal<boolean>;
readonly turnBusy: ReadonlySignal<boolean>;
readonly activeTurnId: ReadonlySignal<string | null>;
readonly runtimeSnapshot: ReadonlySignal<RuntimeSnapshot>;
}
export function createChatPanelShellReadModelBinding(initialState: ChatState): ChatPanelShellReadModelBinding {
const connection = signal(initialState.connection);
const threadList = signal(initialState.threadList);
const panelThread = signal(initialState.panelThread);
const activeThread = computed(() => (panelThread.value.kind === "active" ? panelThread.value.thread : null));
const runtime = signal(initialState.runtime);
const turn = signal(initialState.turn);
const threadStream = signal(initialState.threadStream);
const pendingSubmission = signal(initialState.pendingSubmission);
const requests = signal(initialState.requests);
const composer = signal(initialState.composer);
const ui = signal(initialState.ui);
const turnBusy = computed(() => chatTurnBusy({ turn: turn.value }));
const canonicalStreamItems = computed(() => threadStreamItems(threadStream.value));
const streamItems = computed(() => appendPendingSubmission(canonicalStreamItems.value, pendingSubmission.value));
const hasThreadTurns = computed(() => threadStreamItemsHaveThreadTurns(canonicalStreamItems.value));
const activeThreadIdSignal = computed(() => activeThread.value?.id ?? null);
const activeThreadCwd = computed(() => activeThread.value?.cwd ?? null);
const activeThreadTokenUsage = computed(() => activeThread.value?.tokenUsage ?? null);
const activeThreadGoal = computed(() => activeThread.value?.goal ?? null);
const signals: ChatPanelShellSignals = {
connection,
threadList,
panelThread,
activeThread,
runtime,
turn,
threadStream,
pendingSubmission,
requests,
composer,
ui,
turnBusy,
activeTurnId: computed(() => activeTurnId({ turn: turn.value })),
activeThreadId: activeThreadIdSignal,
activeThreadCwd,
activeThreadGoal,
threadStreamItems: streamItems,
threadStreamStableItems: computed(() =>
threadStream.value.activeSegment
? threadStreamStableItems(threadStream.value)
: appendPendingSubmission(threadStreamStableItems(threadStream.value), pendingSubmission.value),
),
threadStreamActiveItems: computed(() =>
threadStream.value.activeSegment
? appendPendingSubmission(threadStreamActiveItems(threadStream.value), pendingSubmission.value)
: threadStreamActiveItems(threadStream.value),
),
threadStreamRollbackCandidate: computed(() =>
turnBusy.value || activeThread.value?.lifetime?.kind === "ephemeral" || activeThread.value?.provenance?.kind === "subagent"
? null
: threadStreamRollbackCandidateFromItems(canonicalStreamItems.value),
),
threadStreamForkCandidates: computed(() =>
turnBusy.value || activeThread.value?.lifetime?.kind === "ephemeral" || activeThread.value?.provenance?.kind === "subagent"
? []
: forkCandidatesFromItems(canonicalStreamItems.value),
),
threadStreamImplementPlanTarget: computed(() =>
implementPlanTarget({
activeThread: activeThread.value,
turn: turn.value,
runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } },
threadStream: threadStream.value,
}),
),
webSubmissionPending: computed(() => pendingSubmission.value !== null),
webSubmissionCancellable: computed(() => pendingSubmission.value?.phase === "cancellable"),
threadStreamDisclosures: createThreadStreamDisclosuresSignal(ui),
threadStreamForkMenuItemId: computed(() => ui.value.threadStreamActionMenu.forkMenuItemId),
hasThreadTurns,
goalEditor: computed(() => ui.value.goalEditor),
goalObjectiveExpanded: computed(() => ui.value.disclosures.goalObjectiveExpanded),
toolbarRuntimeSnapshot: computed(() =>
runtimeSnapshotForChatSlices({
runtimeConfig: connection.value.runtimeConfig,
activeThread: { id: activeThreadIdSignal.value, tokenUsage: activeThreadTokenUsage.value },
runtime: runtime.value,
rateLimit: connection.value.rateLimit,
hasThreadTurns: false,
availableModels: connection.value.availableModels,
}),
),
composerRuntimeSnapshot: computed(() =>
runtimeSnapshotForChatSlices({
runtimeConfig: connection.value.runtimeConfig,
activeThread: { id: activeThreadIdSignal.value, tokenUsage: activeThreadTokenUsage.value },
runtime: runtime.value,
rateLimit: connection.value.rateLimit,
hasThreadTurns: hasThreadTurns.value,
availableModels: connection.value.availableModels,
}),
),
};
const readModel = shellReadModelFromSignals(signals);
return {
readModel,
sync: (nextState) => {
syncShellSignals(signals, nextState);
},
};
}
function syncShellSignals(signals: ChatPanelShellSignals, nextState: ChatState): void {
batch(() => {
if (signals.connection.value !== nextState.connection) signals.connection.value = nextState.connection;
if (signals.threadList.value !== nextState.threadList) signals.threadList.value = nextState.threadList;
if (signals.panelThread.value !== nextState.panelThread) signals.panelThread.value = nextState.panelThread;
if (signals.runtime.value !== nextState.runtime) signals.runtime.value = nextState.runtime;
if (signals.turn.value !== nextState.turn) signals.turn.value = nextState.turn;
if (signals.threadStream.value !== nextState.threadStream) signals.threadStream.value = nextState.threadStream;
if (signals.pendingSubmission.value !== nextState.pendingSubmission) signals.pendingSubmission.value = nextState.pendingSubmission;
if (signals.requests.value !== nextState.requests) signals.requests.value = nextState.requests;
if (signals.composer.value !== nextState.composer) signals.composer.value = nextState.composer;
if (signals.ui.value !== nextState.ui) signals.ui.value = nextState.ui;
});
}
function appendPendingSubmission(
items: readonly ThreadStreamItem[],
pendingSubmission: ChatState["pendingSubmission"],
): readonly ThreadStreamItem[] {
return pendingSubmission ? [...items, pendingSubmission.item] : items;
}
function shellReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelShellReadModel {
return {
toolbar: toolbarReadModelFromSignals(signals),
goal: goalReadModelFromSignals(signals),
threadStream: threadStreamReadModelFromSignals(signals),
composer: composerReadModelFromSignals(signals),
};
}
function toolbarReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelToolbarReadModel {
return {
threads: computed(() => signals.threadList.value.listedThreads),
activeThreadId: signals.activeThreadId,
activeThreadSubagent: computed(() => signals.activeThread.value?.provenance?.kind === "subagent"),
turnBusy: signals.turnBusy,
runtimeSnapshot: signals.toolbarRuntimeSnapshot,
toolbarPanel: computed(() => signals.ui.value.toolbarPanel),
archiveConfirmThreadId: computed(() => signals.ui.value.archiveConfirmThreadId),
rename: computed(() => signals.ui.value.rename),
diagnostics: computed(() => toolbarDiagnosticState(signals.connection.value)),
debug: computed(() => toolbarDebugState(signals, signals.connection.value)),
};
}
function toolbarDiagnosticState(connection: ChatState["connection"]): ChatPanelToolbarDiagnosticState {
return {
initializeResponse: connection.initializeResponse,
serverDiagnostics: connection.serverDiagnostics,
};
}
function toolbarDebugState(signals: ChatPanelShellSignals, connection: ChatState["connection"]): ChatPanelToolbarDebugState {
return {
activeThreadId: signals.activeThreadId.value,
connection: {
phase: connection.phase,
statusText: connection.statusText,
initializeResponse: connection.initializeResponse,
rateLimit: connection.rateLimit,
serverDiagnostics: connection.serverDiagnostics,
},
runtimeConfig: connection.runtimeConfig,
runtime: signals.runtime.value,
availableModels: connection.availableModels,
};
}
function goalReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelGoalReadModel {
return {
goal: signals.activeThreadGoal,
goalEditor: signals.goalEditor,
goalObjectiveExpanded: signals.goalObjectiveExpanded,
};
}
function threadStreamReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelThreadStreamReadModel {
return {
activeThreadId: signals.activeThreadId,
activeThreadCwd: signals.activeThreadCwd,
activeTurnId: signals.activeTurnId,
historyCursor: computed(() => signals.threadStream.value.historyCursor),
loadingHistory: computed(() => signals.threadStream.value.loadingHistory),
turnDiffs: computed(() => signals.threadStream.value.turnDiffs),
items: signals.threadStreamItems,
stableItems: signals.threadStreamStableItems,
activeItems: signals.threadStreamActiveItems,
requests: signals.requests,
disclosures: signals.threadStreamDisclosures,
forkMenuItemId: signals.threadStreamForkMenuItemId,
rollbackCandidate: signals.threadStreamRollbackCandidate,
forkCandidates: signals.threadStreamForkCandidates,
implementPlanTarget: signals.threadStreamImplementPlanTarget,
};
}
function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelComposerReadModel {
return {
connection: {
phase: computed(() => signals.connection.value.phase),
runtimeConfig: computed(() => signals.connection.value.runtimeConfig),
availableModels: computed(() => signals.connection.value.availableModels),
},
activeListedThreadName: computed(() => activeListedThreadName(signals)),
sideChatActive: computed(() => signals.activeThread.value?.lifetime?.kind === "ephemeral"),
sideChatSourceTitle: computed(() => {
const lifetime = signals.activeThread.value?.lifetime;
return lifetime?.kind === "ephemeral" ? lifetime.sourceThreadTitle : null;
}),
draft: computed(() => signals.composer.value.draft),
suggestions: computed(() => signals.composer.value.suggestions),
selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected),
activeThreadId: signals.activeThreadId,
activeThreadSubagent: computed(() => signals.activeThread.value?.provenance?.kind === "subagent"),
webSubmissionPending: signals.webSubmissionPending,
webSubmissionCancellable: signals.webSubmissionCancellable,
turnBusy: signals.turnBusy,
activeTurnId: signals.activeTurnId,
runtimeSnapshot: signals.composerRuntimeSnapshot,
};
}
function activeListedThreadName(signals: ChatPanelShellSignals): string | null {
const threadId = signals.activeThreadId.value;
if (!threadId) return null;
return projectedThreadName(signals, threadId);
}
function projectedThreadName(signals: ChatPanelShellSignals, threadId: string): string | null {
const thread = signals.threadList.value.listedThreads.find((item) => item.id === threadId);
return thread ? explicitThreadName(thread) : null;
}
function createThreadStreamDisclosuresSignal(ui: Signal<ChatState["ui"]>): ReadonlySignal<ChatPanelThreadStreamDisclosureState> {
let previous: ChatPanelThreadStreamDisclosureState | null = null;
return computed(() => {
const disclosures = ui.value.disclosures;
if (
previous &&
previous.details === disclosures.details &&
previous.activityGroups === disclosures.activityGroups &&
previous.textDetails === disclosures.textDetails &&
previous.userDialogueExpanded === disclosures.userDialogueExpanded &&
previous.approvalDetails === disclosures.approvalDetails
) {
return previous;
}
previous = {
details: disclosures.details,
activityGroups: disclosures.activityGroups,
textDetails: disclosures.textDetails,
userDialogueExpanded: disclosures.userDialogueExpanded,
approvalDetails: disclosures.approvalDetails,
};
return previous;
});
}

View file

@ -0,0 +1,151 @@
import { explicitThreadName } from "../../../domain/threads/model";
import { threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot";
import { activeThreadState, type ChatActiveThreadState, type ChatState } from "../application/state/root-reducer";
import { threadStreamItems } from "../application/state/thread-stream";
import { activeTurnId, chatTurnBusy } from "../application/turns/turn-state";
const hasThreadTurnsByStream = new WeakMap<ChatState["threadStream"], boolean>();
export interface ChatPanelToolbarModel {
readonly threads: ChatState["threadList"]["listedThreads"];
readonly activeThreadId: string | null;
readonly activeThreadSubagent: boolean;
readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"];
readonly turnBusy: boolean;
readonly connection: ChatState["connection"];
readonly runtime: ChatState["runtime"];
readonly toolbarPanel: ChatState["ui"]["toolbarPanel"];
readonly archiveConfirmThreadId: ChatState["ui"]["archiveConfirmThreadId"];
readonly rename: ChatState["ui"]["rename"];
}
export interface ChatPanelGoalModel {
readonly goal: ChatActiveThreadState["goal"];
readonly goalEditor: ChatState["ui"]["goalEditor"];
readonly goalObjectiveExpanded: ChatState["ui"]["disclosures"]["goalObjectiveExpanded"];
}
export interface ChatPanelThreadStreamModel {
readonly activeThreadId: string | null;
readonly activeThreadCwd: ChatActiveThreadState["cwd"];
readonly activeThreadLifetime: ChatActiveThreadState["lifetime"];
readonly activeThreadProvenance: ChatActiveThreadState["provenance"];
readonly turn: ChatState["turn"];
readonly runtimeCollaborationMode: ChatState["runtime"]["pending"]["collaborationMode"];
readonly threadStream: ChatState["threadStream"];
readonly pendingSubmission: ChatState["pendingSubmission"];
readonly requests: ChatState["requests"];
readonly disclosureDetails: ChatState["ui"]["disclosures"]["details"];
readonly disclosureActivityGroups: ChatState["ui"]["disclosures"]["activityGroups"];
readonly disclosureTextDetails: ChatState["ui"]["disclosures"]["textDetails"];
readonly disclosureUserDialogueExpanded: ChatState["ui"]["disclosures"]["userDialogueExpanded"];
readonly disclosureApprovalDetails: ChatState["ui"]["disclosures"]["approvalDetails"];
readonly forkMenuItemId: ChatState["ui"]["threadStreamActionMenu"]["forkMenuItemId"];
}
export interface ChatPanelComposerModel {
readonly connectionPhase: ChatState["connection"]["phase"];
readonly runtimeConfig: ChatState["connection"]["runtimeConfig"];
readonly availableModels: ChatState["connection"]["availableModels"];
readonly rateLimit: ChatState["connection"]["rateLimit"];
readonly activeListedThreadName: string | null;
readonly sideChatActive: boolean;
readonly sideChatSourceTitle: string | null;
readonly draft: ChatState["composer"]["draft"];
readonly suggestions: ChatState["composer"]["suggestions"];
readonly selectedSuggestionIndex: ChatState["composer"]["suggestSelected"];
readonly activeThreadId: string | null;
readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"];
readonly activeThreadSubagent: boolean;
readonly webSubmissionPending: boolean;
readonly webSubmissionCancellable: boolean;
readonly turnBusy: boolean;
readonly activeTurnId: string | null;
readonly runtime: ChatState["runtime"];
readonly hasThreadTurns: boolean;
}
export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel {
const activeThread = activeThreadState(state);
return {
threads: state.threadList.listedThreads,
activeThreadId: activeThread?.id ?? null,
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
turnBusy: chatTurnBusy(state),
connection: state.connection,
runtime: state.runtime,
toolbarPanel: state.ui.toolbarPanel,
archiveConfirmThreadId: state.ui.archiveConfirmThreadId,
rename: state.ui.rename,
};
}
export function selectChatPanelGoal(state: ChatState): ChatPanelGoalModel {
return {
goal: activeThreadState(state)?.goal ?? null,
goalEditor: state.ui.goalEditor,
goalObjectiveExpanded: state.ui.disclosures.goalObjectiveExpanded,
};
}
export function selectChatPanelThreadStream(state: ChatState): ChatPanelThreadStreamModel {
const activeThread = activeThreadState(state);
return {
activeThreadId: activeThread?.id ?? null,
activeThreadCwd: activeThread?.cwd ?? null,
activeThreadLifetime: activeThread?.lifetime ?? null,
activeThreadProvenance: activeThread?.provenance ?? null,
turn: state.turn,
runtimeCollaborationMode: state.runtime.pending.collaborationMode,
threadStream: state.threadStream,
pendingSubmission: state.pendingSubmission,
requests: state.requests,
disclosureDetails: state.ui.disclosures.details,
disclosureActivityGroups: state.ui.disclosures.activityGroups,
disclosureTextDetails: state.ui.disclosures.textDetails,
disclosureUserDialogueExpanded: state.ui.disclosures.userDialogueExpanded,
disclosureApprovalDetails: state.ui.disclosures.approvalDetails,
forkMenuItemId: state.ui.threadStreamActionMenu.forkMenuItemId,
};
}
export function selectChatPanelComposer(state: ChatState): ChatPanelComposerModel {
const activeThread = activeThreadState(state);
const activeThreadId = activeThread?.id ?? null;
const lifetime = activeThread?.lifetime;
return {
connectionPhase: state.connection.phase,
runtimeConfig: state.connection.runtimeConfig,
availableModels: state.connection.availableModels,
rateLimit: state.connection.rateLimit,
activeListedThreadName: activeThreadId ? projectedThreadName(state, activeThreadId) : null,
sideChatActive: lifetime?.kind === "ephemeral",
sideChatSourceTitle: lifetime?.kind === "ephemeral" ? lifetime.sourceThreadTitle : null,
draft: state.composer.draft,
suggestions: state.composer.suggestions,
selectedSuggestionIndex: state.composer.suggestSelected,
activeThreadId,
activeThreadTokenUsage: activeThread?.tokenUsage ?? null,
activeThreadSubagent: activeThread?.provenance?.kind === "subagent",
webSubmissionPending: state.pendingSubmission !== null,
webSubmissionCancellable: state.pendingSubmission?.phase === "cancellable",
turnBusy: chatTurnBusy(state),
activeTurnId: activeTurnId(state),
runtime: state.runtime,
hasThreadTurns: hasThreadTurns(state.threadStream),
};
}
function hasThreadTurns(threadStream: ChatState["threadStream"]): boolean {
const cached = hasThreadTurnsByStream.get(threadStream);
if (cached !== undefined) return cached;
const result = threadStreamItemsHaveThreadTurns(threadStreamItems(threadStream));
hasThreadTurnsByStream.set(threadStream, result);
return result;
}
function projectedThreadName(state: ChatState, threadId: string): string | null {
const thread = state.threadList.listedThreads.find((item) => item.id === threadId);
return thread ? explicitThreadName(thread) : null;
}

View file

@ -1,9 +1,11 @@
import type { ComponentChild as UiNode } from "preact";
import { useMemo } from "preact/hooks";
import { listenDomEvent } from "../../../shared/dom/events.dom";
import { renderUiRoot, unmountUiRoot } from "../../../shared/dom/preact-root.dom";
import type { ChatStateStore } from "../application/state/store";
import type { ToolbarActions } from "../ui/toolbar";
import { type ChatPanelShellReadModelBinding, createChatPanelShellReadModelBinding } from "./shell-read-model";
import { useChatSelector } from "./chat-state-selector";
import { selectChatPanelComposer, selectChatPanelGoal, selectChatPanelThreadStream, selectChatPanelToolbar } from "./shell-selectors";
import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerPresenter } from "./surface/composer-projection";
import { ChatPanelGoal, type ChatPanelGoalSurface } from "./surface/goal-projection";
import { ChatPanelThreadStream, type ChatPanelThreadStreamPresenter } from "./surface/thread-stream-presenter";
@ -30,10 +32,7 @@ export interface ChatPanelShellProps {
interface ChatPanelShellMount {
props: ChatPanelShellProps;
stateStore: ChatStateStore;
unsubscribe: () => void;
stopStatusBarClearanceSync: () => void;
shellReadModelBinding: ChatPanelShellReadModelBinding;
}
const shellMounts = new WeakMap<HTMLElement, ChatPanelShellMount>();
@ -41,14 +40,13 @@ const shellMounts = new WeakMap<HTMLElement, ChatPanelShellMount>();
export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void {
container.addClass("codex-panel");
const existing = shellMounts.get(container);
const mount = existing?.stateStore === props.stateStore ? existing : createShellMount(container, props);
const mount = existing ?? createShellMount(container, props);
renderMountedShell(container, mount, props);
}
export function unmountChatPanelShell(container: HTMLElement | null): void {
if (!container) return;
const mount = shellMounts.get(container);
mount?.unsubscribe();
mount?.stopStatusBarClearanceSync();
shellMounts.delete(container);
unmountUiRoot(container);
@ -57,17 +55,9 @@ export function unmountChatPanelShell(container: HTMLElement | null): void {
function createShellMount(container: HTMLElement, props: ChatPanelShellProps): ChatPanelShellMount {
const existing = shellMounts.get(container);
existing?.unsubscribe();
existing?.stopStatusBarClearanceSync();
const mount: ChatPanelShellMount = {
props,
stateStore: props.stateStore,
shellReadModelBinding: createChatPanelShellReadModelBinding(props.stateStore.getState()),
unsubscribe: props.stateStore.subscribe(() => {
const current = shellMounts.get(container);
if (!current) return;
current.shellReadModelBinding.sync(props.stateStore.getState());
}),
stopStatusBarClearanceSync: startStatusBarClearanceSync(container),
};
shellMounts.set(container, mount);
@ -80,7 +70,7 @@ function renderMountedShell(container: HTMLElement, mount: ChatPanelShellMount,
container.replaceChildren();
}
syncStatusBarClearance(container);
renderUiRoot(container, <ChatPanelShell {...props} shellReadModelBinding={mount.shellReadModelBinding} />);
renderUiRoot(container, <ChatPanelShell {...props} />);
mount.props = props;
}
@ -104,32 +94,72 @@ function shellRegion(container: HTMLElement, region: string): HTMLElement | null
return container.querySelector<HTMLElement>(`:scope > [data-codex-panel-shell-region="${region}"]`);
}
function ChatPanelShell({
showToolbar,
parts,
shellReadModelBinding,
}: ChatPanelShellProps & { shellReadModelBinding: ChatPanelShellReadModelBinding }): UiNode {
const readModel = shellReadModelBinding.readModel;
function ChatPanelShell({ stateStore, showToolbar, parts }: ChatPanelShellProps): UiNode {
return (
<>
{showToolbar ? (
<div className="codex-panel__toolbar" data-codex-panel-shell-region="toolbar">
<ChatPanelToolbar model={readModel.toolbar} surface={parts.toolbar.surface} actions={parts.toolbar.actions} />
<div key="toolbar" className="codex-panel__toolbar" data-codex-panel-shell-region="toolbar">
<ChatPanelToolbarRegion stateStore={stateStore} surface={parts.toolbar.surface} actions={parts.toolbar.actions} />
</div>
) : null}
<div className="codex-panel__body" data-codex-panel-shell-region="body">
<div key="body" className="codex-panel__body" data-codex-panel-shell-region="body">
<div className="codex-panel__region codex-panel__region--goal" data-codex-panel-shell-region="goal">
<ChatPanelGoal model={readModel.goal} surface={parts.goal} />
<ChatPanelGoalRegion stateStore={stateStore} surface={parts.goal} />
</div>
<ChatPanelThreadStream model={readModel.threadStream} presenter={parts.threadStream} />
<ChatPanelThreadStreamRegion stateStore={stateStore} presenter={parts.threadStream} />
<div className="codex-panel__region codex-panel__region--composer" data-codex-panel-shell-region="composer">
<ChatPanelComposer model={readModel.composer} presenter={parts.composer.presenter} actions={parts.composer.actions} />
<ChatPanelComposerRegion stateStore={stateStore} presenter={parts.composer.presenter} actions={parts.composer.actions} />
</div>
</div>
</>
);
}
function ChatPanelToolbarRegion({
stateStore,
surface,
actions,
}: {
stateStore: ChatStateStore;
surface: ChatPanelToolbarSurface;
actions: ToolbarActions;
}): UiNode {
const model = useChatSelector(stateStore, selectChatPanelToolbar);
return useMemo(
() => <ChatPanelToolbar model={model} stateStore={stateStore} surface={surface} actions={actions} />,
[model, stateStore, surface, actions],
);
}
function ChatPanelGoalRegion({ stateStore, surface }: { stateStore: ChatStateStore; surface: ChatPanelGoalSurface }): UiNode {
const model = useChatSelector(stateStore, selectChatPanelGoal);
return useMemo(() => <ChatPanelGoal model={model} surface={surface} />, [model, surface]);
}
function ChatPanelThreadStreamRegion({
stateStore,
presenter,
}: {
stateStore: ChatStateStore;
presenter: ChatPanelThreadStreamPresenter;
}): UiNode {
const model = useChatSelector(stateStore, selectChatPanelThreadStream);
return useMemo(() => <ChatPanelThreadStream model={model} presenter={presenter} />, [model, presenter]);
}
function ChatPanelComposerRegion({
stateStore,
presenter,
actions,
}: {
stateStore: ChatStateStore;
presenter: ChatPanelComposerPresenter;
actions: ChatPanelComposerActions;
}): UiNode {
const model = useChatSelector(stateStore, selectChatPanelComposer);
return useMemo(() => <ChatPanelComposer model={model} presenter={presenter} actions={actions} />, [model, presenter, actions]);
}
function startStatusBarClearanceSync(container: HTMLElement): () => void {
const win = container.ownerDocument.defaultView;
if (!win) return () => undefined;

View file

@ -3,12 +3,13 @@ import { h } from "preact";
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import { sortedModelMetadata } from "../../../../domain/catalog/metadata";
import { runtimeConfigOrDefault } from "../../../../domain/runtime/config";
import { runtimeSnapshotForChatSlices } from "../../application/runtime/snapshot";
import { compactReasoningEffortLabel } from "../../domain/runtime/labels";
import { resolveRuntimeControls } from "../../domain/runtime/resolution";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { contextSummary } from "../../presentation/runtime/status";
import { type ComposerMetaViewModel, ComposerShell, type ComposerShellProps } from "../../ui/composer";
import type { ChatPanelComposerReadModel } from "../shell-read-model";
import type { ChatPanelComposerModel } from "../shell-selectors";
type ChatPanelComposerMeta = ComposerMetaViewModel;
type ChatPanelComposerContextMeter = ComposerMetaViewModel["context"];
@ -26,7 +27,7 @@ export interface ChatPanelComposerProjectionActions {
}
export interface ChatPanelComposerPresenter {
renderState(model: ChatPanelComposerReadModel, actions: ChatPanelComposerActions): ComposerShellProps;
renderState(model: ChatPanelComposerModel, actions: ChatPanelComposerActions): ComposerShellProps;
}
export interface ChatPanelComposerActions {
@ -34,7 +35,7 @@ export interface ChatPanelComposerActions {
}
interface RuntimeComposerChoicesInput {
readModel: ChatPanelComposerReadModel;
model: ChatPanelComposerModel;
snapshot: RuntimeSnapshot;
requestModel: (model: string) => void;
requestReasoningEffort: (effort: ReasoningEffort) => void;
@ -50,7 +51,7 @@ export function ChatPanelComposer({
presenter,
actions,
}: {
model: ChatPanelComposerReadModel;
model: ChatPanelComposerModel;
presenter: ChatPanelComposerPresenter;
actions: ChatPanelComposerActions;
}): UiNode {
@ -58,18 +59,25 @@ export function ChatPanelComposer({
}
export function chatPanelComposerProjection(
readModel: ChatPanelComposerReadModel,
model: ChatPanelComposerModel,
actions: ChatPanelComposerProjectionActions,
): ChatPanelComposerProjection {
const snapshot = readModel.runtimeSnapshot.value;
const snapshot = runtimeSnapshotForChatSlices({
runtimeConfig: model.runtimeConfig,
activeThread: { id: model.activeThreadId, tokenUsage: model.activeThreadTokenUsage },
runtime: model.runtime,
rateLimit: model.rateLimit,
hasThreadTurns: model.hasThreadTurns,
availableModels: model.availableModels,
});
return {
placeholder: readModel.activeThreadSubagent.value
placeholder: model.activeThreadSubagent
? "Agent thread is read-only."
: composerPlaceholder(activeComposerThreadName(readModel), readModel.sideChatActive.value, readModel.sideChatSourceTitle.value),
: composerPlaceholder(activeComposerThreadName(model), model.sideChatActive, model.sideChatSourceTitle),
meta: {
...composerMetaViewModel(readModel, snapshot),
...composerMetaViewModel(model, snapshot),
...runtimeComposerChoices({
readModel,
model,
snapshot,
requestModel: (model) => void actions.requestModel(model),
requestReasoningEffort: (effort) => void actions.requestReasoningEffort(effort),
@ -79,10 +87,10 @@ export function chatPanelComposerProjection(
}
function composerMetaViewModel(
readModel: ChatPanelComposerReadModel,
model: ChatPanelComposerModel,
snapshot: RuntimeSnapshot,
): Omit<ChatPanelComposerMeta, "modelChoices" | "effortChoices"> {
const phase = readModel.connection.phase.value;
const phase = model.connectionPhase;
if (phase.kind === "failed" || phase.kind === "disconnected") {
return {
fatal: "Codex app-server disconnected",
@ -96,7 +104,7 @@ function composerMetaViewModel(
};
}
const config = runtimeConfigOrDefault(readModel.connection.runtimeConfig.value);
const config = runtimeConfigOrDefault(model.runtimeConfig);
const resolution = resolveRuntimeControls(snapshot, config);
const context = contextSummary(snapshot);
const selectedModel = resolution.model.effective;
@ -133,10 +141,10 @@ function runtimeComposerChoices(input: RuntimeComposerChoicesInput): {
modelChoices: ChatPanelComposerRuntimeChoice[];
effortChoices: ChatPanelComposerRuntimeChoice[];
} {
const config = runtimeConfigOrDefault(input.readModel.connection.runtimeConfig.value);
const config = runtimeConfigOrDefault(input.model.runtimeConfig);
const resolution = resolveRuntimeControls(input.snapshot, config);
const effectiveModel = resolution.model.effective;
const models = sortedModelMetadata(input.readModel.connection.availableModels.value);
const models = sortedModelMetadata(input.model.availableModels);
const modelChoices: ChatPanelComposerRuntimeChoice[] = models.slice(0, 12).map((model) => ({
label: model.model,
selected: effectiveModel === model.model,
@ -187,9 +195,8 @@ function onOffLabel(active: boolean): string {
return active ? "on" : "off";
}
function activeComposerThreadName(model: ChatPanelComposerReadModel): string | null {
const activeThreadId = model.activeThreadId.value;
return activeThreadId ? model.activeListedThreadName.value : null;
function activeComposerThreadName(model: ChatPanelComposerModel): string | null {
return model.activeThreadId ? model.activeListedThreadName : null;
}
const CONTEXT_DOT_WIDTH = 4;

View file

@ -3,7 +3,7 @@ import { h } from "preact";
import type { SendShortcut } from "../../../../domain/input/send-shortcut";
import type { GoalPanelActions, GoalPanelDisplayState, GoalPanelEditorState, GoalPanelOptions } from "../../ui/goal";
import { GoalPanel } from "../../ui/goal";
import type { ChatPanelGoalReadModel } from "../shell-read-model";
import type { ChatPanelGoalModel } from "../shell-selectors";
interface ChatPanelGoalActions {
saveObjective: (objective: string, tokenBudget: number | null) => Promise<boolean>;
@ -20,22 +20,22 @@ export interface ChatPanelGoalSurface {
actions: ChatPanelGoalActions;
}
export function ChatPanelGoal({ model, surface }: { model: ChatPanelGoalReadModel; surface: ChatPanelGoalSurface }): UiNode {
export function ChatPanelGoal({ model, surface }: { model: ChatPanelGoalModel; surface: ChatPanelGoalSurface }): UiNode {
const props = chatPanelGoalViewModel(surface, model);
return h(GoalPanel, props);
}
interface ChatPanelGoalProjection {
goal: ChatPanelGoalReadModel["goal"]["value"];
goal: ChatPanelGoalModel["goal"];
goalThreadId: string | null;
editor: GoalPanelEditorState;
display: GoalPanelDisplayState;
}
function chatPanelGoalProjection(model: ChatPanelGoalReadModel): ChatPanelGoalProjection {
const goal = model.goal.value;
function chatPanelGoalProjection(model: ChatPanelGoalModel): ChatPanelGoalProjection {
const goal = model.goal;
const goalThreadId = goal?.threadId ?? null;
const goalEditor = model.goalEditor.value;
const goalEditor = model.goalEditor;
const editor =
goalEditor.kind === "editing"
? { editing: true, objectiveDraft: goalEditor.objectiveDraft, tokenBudgetDraft: goalEditor.tokenBudgetDraft }
@ -45,16 +45,16 @@ function chatPanelGoalProjection(model: ChatPanelGoalReadModel): ChatPanelGoalPr
goalThreadId,
editor,
display: {
objectiveExpanded: goalThreadId ? model.goalObjectiveExpanded.value.has(goalThreadId) : false,
objectiveExpanded: goalThreadId ? model.goalObjectiveExpanded.has(goalThreadId) : false,
},
};
}
function chatPanelGoalViewModel(
surface: ChatPanelGoalSurface,
model: ChatPanelGoalReadModel,
model: ChatPanelGoalModel,
): {
goal: ChatPanelGoalReadModel["goal"]["value"];
goal: ChatPanelGoalModel["goal"];
actions: GoalPanelActions;
options: GoalPanelOptions;
editor: GoalPanelEditorState;

View file

@ -9,18 +9,18 @@ import type { ChatStateStore } from "../../application/state/store";
import type { ThreadStreamScrollPortBinding } from "../../ui/thread-stream/flow-scroll.measure";
import { renderStreamMarkdown, ThreadStreamMarkdownRenderer } from "../../ui/thread-stream/markdown-renderer.obsidian";
import { ThreadStreamViewport, type ThreadStreamViewportState } from "../../ui/thread-stream/stream-blocks";
import type { ChatPanelThreadStreamReadModel } from "../shell-read-model";
import type { ChatPanelThreadStreamModel } from "../shell-selectors";
import { type ChatThreadStreamSurfaceContext, threadStreamSurfaceProjectionFromModel } from "./thread-stream-projection";
export interface ChatPanelThreadStreamPresenter {
renderState(model: ChatPanelThreadStreamReadModel): ThreadStreamViewportState;
renderState(model: ChatPanelThreadStreamModel): ThreadStreamViewportState;
}
export function ChatPanelThreadStream({
model,
presenter,
}: {
model: ChatPanelThreadStreamReadModel;
model: ChatPanelThreadStreamModel;
presenter: ChatPanelThreadStreamPresenter;
}): UiNode {
return h(ThreadStreamViewport, {
@ -112,7 +112,7 @@ export class ThreadStreamPresenter {
};
}
renderState(model: ChatPanelThreadStreamReadModel): ThreadStreamViewportState {
renderState(model: ChatPanelThreadStreamModel): ThreadStreamViewportState {
const projection = threadStreamSurfaceProjectionFromModel(model, this.surfaceContext);
return {

View file

@ -1,14 +1,28 @@
import type { TurnDiffViewState } from "../../../turn-diff/model";
import { type PendingRequestBlockActions, pendingRequestBlockStateFromRequestState } from "../../application/pending-requests/block";
import type { ChatRequestState } from "../../application/pending-requests/state";
import type { ThreadStreamRollbackCandidate } from "../../application/state/thread-stream";
import {
type ThreadStreamRollbackCandidate,
threadStreamActiveItems,
threadStreamItems,
threadStreamRollbackCandidateFromItems,
threadStreamStableItems,
} from "../../application/state/thread-stream";
import { implementPlanTarget } from "../../application/turns/plan-implementation";
import { activeTurnId, chatTurnBusy } from "../../application/turns/turn-state";
import { pendingRequestsSignature } from "../../domain/pending-requests/signatures";
import { type ForkCandidate, type PlanImplementationTarget, threadStreamSegmentsEmpty } from "../../domain/thread-stream/selectors";
import type { ThreadStreamItem } from "../../domain/thread-stream/items";
import {
type ForkCandidate,
forkCandidatesFromItems,
type PlanImplementationTarget,
threadStreamSegmentsEmpty,
} from "../../domain/thread-stream/selectors";
import { type PendingRequestBlockSnapshot, pendingRequestBlockSnapshotFromState } from "../../presentation/pending-requests/view-model";
import type { ThreadStreamTextActionTargets } from "../../presentation/thread-stream/text-view";
import { type ThreadStreamViewBlock, threadStreamViewBlocks } from "../../presentation/thread-stream/view-model";
import type { ThreadStreamContext, ThreadStreamDisclosureBucket, ThreadStreamDisclosureState } from "../../ui/thread-stream/context";
import type { ChatPanelThreadStreamReadModel } from "../shell-read-model";
import type { ChatPanelThreadStreamModel } from "../shell-selectors";
interface ChatThreadStreamActions {
rollbackThread: (threadId: string) => void;
@ -56,7 +70,7 @@ export interface ThreadStreamSurfaceProjection {
}
export function threadStreamSurfaceProjectionFromModel(
model: ChatPanelThreadStreamReadModel,
model: ChatPanelThreadStreamModel,
context: ChatThreadStreamSurfaceContext,
): ThreadStreamSurfaceProjection {
const projection = threadStreamStateProjection(model, context);
@ -114,44 +128,70 @@ function threadStreamContextFromProjection(
}
function threadStreamStateProjection(
model: ChatPanelThreadStreamReadModel,
model: ChatPanelThreadStreamModel,
context: ChatThreadStreamSurfaceContext,
): ThreadStreamStateProjection {
const stableItems = model.stableItems.value;
const activeItems = model.activeItems.value;
const disclosures = model.disclosures.value;
const workspaceRoot = model.activeThreadCwd.value ?? context.vaultPath;
const textActionTargetsByItemId = textActionTargetsForThreadStreamItems(
model.rollbackCandidate.value,
model.forkCandidates.value,
model.implementPlanTarget.value,
);
const canonicalItems = threadStreamItems(model.threadStream);
const stableItems = model.threadStream.activeSegment
? threadStreamStableItems(model.threadStream)
: appendPendingSubmission(threadStreamStableItems(model.threadStream), model.pendingSubmission);
const activeItems = model.threadStream.activeSegment
? appendPendingSubmission(threadStreamActiveItems(model.threadStream), model.pendingSubmission)
: threadStreamActiveItems(model.threadStream);
const items = appendPendingSubmission(canonicalItems, model.pendingSubmission);
const disclosures = {
details: model.disclosureDetails,
activityGroups: model.disclosureActivityGroups,
textDetails: model.disclosureTextDetails,
userDialogueExpanded: model.disclosureUserDialogueExpanded,
approvalDetails: model.disclosureApprovalDetails,
};
const workspaceRoot = model.activeThreadCwd ?? context.vaultPath;
const turnBusy = chatTurnBusy(model.turn);
const actionCandidatesAllowed =
!turnBusy && model.activeThreadLifetime?.kind !== "ephemeral" && model.activeThreadProvenance?.kind !== "subagent";
const rollbackCandidate = actionCandidatesAllowed ? threadStreamRollbackCandidateFromItems(canonicalItems) : null;
const forkCandidates = actionCandidatesAllowed ? forkCandidatesFromItems(canonicalItems) : [];
const planTarget = implementPlanTarget({
activeThread: model.activeThreadId ? { id: model.activeThreadId, provenance: model.activeThreadProvenance } : null,
turn: model.turn,
runtime: { pending: { collaborationMode: model.runtimeCollaborationMode } },
threadStream: model.threadStream,
});
const textActionTargetsByItemId = textActionTargetsForThreadStreamItems(rollbackCandidate, forkCandidates, planTarget);
const pendingRequests = threadStreamSegmentsEmpty(stableItems, activeItems)
? null
: pendingRequestSurfaceProjectionFromState(model.requests.value, disclosures.approvalDetails);
: pendingRequestSurfaceProjectionFromState(model.requests, disclosures.approvalDetails);
return {
activeThreadId: model.activeThreadId.value,
activeThreadId: model.activeThreadId,
workspaceRoot,
disclosures,
forkMenuItemId: model.forkMenuItemId.value,
forkMenuItemId: model.forkMenuItemId,
pendingRequests,
viewBlocks: threadStreamViewBlocks({
activeThreadId: model.activeThreadId.value,
activeTurnId: model.activeTurnId.value,
historyCursor: model.historyCursor.value,
loadingHistory: model.loadingHistory.value,
items: model.items.value,
activeThreadId: model.activeThreadId,
activeTurnId: activeTurnId(model.turn),
historyCursor: model.threadStream.historyCursor,
loadingHistory: model.threadStream.loadingHistory,
items,
stableItems,
activeItems,
workspaceRoot,
turnDiffs: model.turnDiffs.value,
turnDiffs: model.threadStream.turnDiffs,
textActionTargetsByItemId,
pendingRequests,
}),
};
}
function appendPendingSubmission(
items: readonly ThreadStreamItem[],
pendingSubmission: ChatPanelThreadStreamModel["pendingSubmission"],
): readonly ThreadStreamItem[] {
return pendingSubmission ? [...items, pendingSubmission.item] : items;
}
function textActionTargetsForThreadStreamItems(
rollbackCandidate: ThreadStreamRollbackCandidate | null,
forkCandidates: readonly ForkCandidate[],

View file

@ -4,13 +4,16 @@ import { h } from "preact";
import { CLIENT_VERSION } from "../../../../constants";
import type { Thread } from "../../../../domain/threads/model";
import { threadRowCoreProjection } from "../../../threads/list/row-projection";
import { runtimeSnapshotForChatSlices } from "../../application/runtime/snapshot";
import { activeThreadState } from "../../application/state/root-reducer";
import type { ChatStateStore } from "../../application/state/store";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { appServerDiagnosticSections } from "../../presentation/runtime/diagnostic-sections";
import { runtimePermissionSections } from "../../presentation/runtime/permission-sections";
import { rateLimitSummary } from "../../presentation/runtime/status";
import { toolInventoryDiagnosticSections } from "../../presentation/runtime/tool-inventory-diagnostic-sections";
import { Toolbar, type ToolbarActions, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar";
import type { ChatPanelToolbarReadModel } from "../shell-read-model";
import type { ChatPanelToolbarModel } from "../shell-selectors";
export interface ChatPanelToolbarSurface {
connection: {
@ -27,7 +30,8 @@ export interface ChatPanelToolbarSurface {
}
interface ToolbarViewModelInput {
model: ChatPanelToolbarReadModel;
model: ChatPanelToolbarModel;
stateStore: ChatStateStore;
snapshot: RuntimeSnapshot;
connected: boolean;
nowMs: number;
@ -47,13 +51,21 @@ interface ToolbarStateProjection {
threads: ToolbarThreadRow[];
}
function chatPanelToolbarViewModel(surface: ChatPanelToolbarSurface, model: ChatPanelToolbarReadModel) {
function chatPanelToolbarViewModel(surface: ChatPanelToolbarSurface, model: ChatPanelToolbarModel, stateStore: ChatStateStore) {
return chatPanelToolbarProjection({
model,
snapshot: model.runtimeSnapshot.value,
stateStore,
snapshot: runtimeSnapshotForChatSlices({
runtimeConfig: model.connection.runtimeConfig,
activeThread: { id: model.activeThreadId, tokenUsage: model.activeThreadTokenUsage },
runtime: model.runtime,
rateLimit: model.connection.rateLimit,
hasThreadTurns: false,
availableModels: model.connection.availableModels,
}),
connected: surface.connection.connected(),
nowMs: surface.clock.nowMs(),
turnBusy: model.turnBusy.value,
turnBusy: model.turnBusy,
vaultPath: surface.settings.vaultPath(),
configuredCommand: surface.settings.configuredCommand(),
archiveExportEnabled: surface.settings.archiveExportEnabled(),
@ -62,21 +74,23 @@ function chatPanelToolbarViewModel(surface: ChatPanelToolbarSurface, model: Chat
export function ChatPanelToolbar({
model,
stateStore,
surface,
actions,
}: {
model: ChatPanelToolbarReadModel;
model: ChatPanelToolbarModel;
stateStore: ChatStateStore;
surface: ChatPanelToolbarSurface;
actions: ToolbarActions;
}): UiNode {
return h(Toolbar, { model: chatPanelToolbarViewModel(surface, model), actions });
return h(Toolbar, { model: chatPanelToolbarViewModel(surface, model, stateStore), actions });
}
function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewModel {
const { model, snapshot } = input;
const projection = toolbarStateProjection(input);
const limit = rateLimitSummary(snapshot, input.nowMs);
const diagnostics = model.diagnostics.value;
const diagnostics = model.connection;
const permissions = runtimePermissionSections({
snapshot,
vaultPath: input.vaultPath,
@ -104,14 +118,15 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo
}
function runtimeDebugDetails(input: ToolbarViewModelInput): string {
const debug = input.model.debug.value;
const connection = debug.connection;
const state = input.stateStore.getState();
const activeThread = activeThreadState(state);
const connection = state.connection;
return JSON.stringify(
{
clientVersion: CLIENT_VERSION,
vaultPath: input.vaultPath,
configuredCommand: input.configuredCommand,
activeThreadId: debug.activeThreadId,
activeThreadId: activeThread?.id ?? null,
connection: {
connected: input.connected,
phase: connection.phase,
@ -123,9 +138,9 @@ function runtimeDebugDetails(input: ToolbarViewModelInput): string {
mcpServers: connection.serverDiagnostics.mcpServers,
},
},
runtimeConfig: debug.runtimeConfig,
runtime: debug.runtime,
availableModels: debug.availableModels,
runtimeConfig: connection.runtimeConfig,
runtime: state.runtime,
availableModels: connection.availableModels,
},
null,
2,
@ -133,28 +148,28 @@ function runtimeDebugDetails(input: ToolbarViewModelInput): string {
}
function toolbarStateProjection(input: {
model: ChatPanelToolbarReadModel;
model: ChatPanelToolbarModel;
turnBusy: boolean;
archiveExportEnabled: boolean;
}): ToolbarStateProjection {
const toolbarPanel = input.model.toolbarPanel.value;
const toolbarPanel = input.model.toolbarPanel;
const historyOpen = toolbarPanel === "history";
const chatActionsOpen = toolbarPanel === "chat-actions";
const statusPanelOpen = toolbarPanel === "status-panel";
return {
newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent.value,
activeThreadChatActionsDisabled: input.model.activeThreadSubagent.value,
newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent,
activeThreadChatActionsDisabled: input.model.activeThreadSubagent,
chatActionsOpen,
historyOpen,
statusPanelOpen,
openPanel: historyOpen ? "history" : chatActionsOpen ? "chat-actions" : statusPanelOpen ? "status" : null,
threads: toolbarThreadRows({
threads: input.model.threads.value,
activeThreadId: input.model.activeThreadId.value,
threads: input.model.threads,
activeThreadId: input.model.activeThreadId,
turnBusy: input.turnBusy,
archiveConfirmThreadId: input.model.archiveConfirmThreadId.value,
archiveConfirmThreadId: input.model.archiveConfirmThreadId,
archiveExportEnabled: input.archiveExportEnabled,
renameState: input.model.rename.value,
renameState: input.model.rename,
}),
};
}
@ -165,7 +180,7 @@ function toolbarThreadRows(input: {
turnBusy: boolean;
archiveConfirmThreadId: string | null;
archiveExportEnabled: boolean;
renameState: ChatPanelToolbarReadModel["rename"]["value"];
renameState: ChatPanelToolbarModel["rename"];
}): ToolbarThreadRow[] {
return input.threads.map((thread) => {
const threadId = thread.id;
@ -188,7 +203,7 @@ function toolbarThreadRows(input: {
});
}
function toolbarActiveRenameState(renameState: ChatPanelToolbarReadModel["rename"]["value"], threadId: string) {
function toolbarActiveRenameState(renameState: ChatPanelToolbarModel["rename"], threadId: string) {
if (renameState.kind === "idle" || renameState.threadId !== threadId) return undefined;
return renameState;
}

View file

@ -0,0 +1,196 @@
// @vitest-environment jsdom
import type { ComponentChild } from "preact";
import { act } from "preact/test-utils";
import { describe, expect, it, type Mock, vi } from "vitest";
import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer";
import type { ChatStateStore } from "../../../../src/features/chat/application/state/store";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { useChatSelector } from "../../../../src/features/chat/panel/chat-state-selector";
import {
selectChatPanelComposer,
selectChatPanelGoal,
selectChatPanelThreadStream,
selectChatPanelToolbar,
} from "../../../../src/features/chat/panel/shell-selectors";
import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-root.dom";
import { chatStateFixture, chatStateWith } from "../support/state";
describe("useChatSelector", () => {
it("catches an update between the render read and subscription", async () => {
const initial = chatStateFixture();
const updated = chatStateWith(initial, { composer: { draft: "after subscribe" } });
const store = controllableStore(initial, (current) => {
current.replaceWithoutNotification(updated);
});
const parent = document.createElement("div");
await act(async () => {
renderUiRoot(parent, <ComposerValue store={store} />);
});
expect(parent.textContent).toBe("after subscribe");
unmountUiRoot(parent);
});
it("unsubscribes on unmount", async () => {
const store = controllableStore(chatStateFixture());
const parent = document.createElement("div");
await act(async () => {
renderUiRoot(parent, <ComposerValue store={store} />);
});
expect(store.listenerCount()).toBe(1);
await act(async () => {
unmountUiRoot(parent);
});
expect(store.listenerCount()).toBe(0);
});
it("moves the subscription when the store is replaced", async () => {
const first = controllableStore(chatStateWith(chatStateFixture(), { composer: { draft: "first" } }));
const second = controllableStore(chatStateWith(chatStateFixture(), { composer: { draft: "second" } }));
const parent = document.createElement("div");
await act(async () => {
renderUiRoot(parent, <ComposerValue store={first} />);
renderUiRoot(parent, <ComposerValue store={second} />);
});
expect(first.listenerCount()).toBe(0);
expect(second.listenerCount()).toBe(1);
expect(parent.textContent).toBe("second");
await act(async () => {
first.replace(chatStateWith(first.getState(), { composer: { draft: "stale" } }));
second.replace(chatStateWith(second.getState(), { composer: { draft: "current" } }));
});
expect(parent.textContent).toBe("current");
unmountUiRoot(parent);
});
it("rerenders only the region whose selected state changed", async () => {
const store = createChatStateStore();
const renders = { toolbar: vi.fn(), goal: vi.fn(), threadStream: vi.fn(), composer: vi.fn() };
const parent = document.createElement("div");
await act(async () => {
renderUiRoot(parent, <SelectorRegions store={store} renders={renders} />);
});
expect(regionRenderCounts(renders)).toEqual([1, 1, 1, 1]);
await act(async () => {
store.dispatch({ type: "composer/draft-set", draft: "draft" });
});
expect(regionRenderCounts(renders)).toEqual([1, 1, 1, 2]);
await act(async () => {
store.dispatch({ type: "ui/goal-editor-started", threadId: null, objective: "Goal", tokenBudget: null });
});
expect(regionRenderCounts(renders)).toEqual([1, 2, 1, 2]);
await act(async () => {
store.dispatch({ type: "ui/panel-set", panel: "history" });
});
expect(regionRenderCounts(renders)).toEqual([2, 2, 1, 2]);
await act(async () => {
store.dispatch({ type: "ui/disclosure-set", bucket: "details", id: "item", open: true });
});
expect(regionRenderCounts(renders)).toEqual([2, 2, 2, 2]);
await act(async () => {
store.dispatch({
type: "thread-stream/system-item-added",
item: { id: "status", kind: "system", role: "system", text: "Status" },
});
});
expect(regionRenderCounts(renders)).toEqual([2, 2, 3, 2]);
await act(async () => {
store.dispatch({
type: "thread-stream/items-replaced",
historyCursor: null,
items: [{ id: "user", kind: "dialogue", dialogueKind: "user", role: "user", text: "Hello", turnId: "turn" }],
});
});
expect(regionRenderCounts(renders)).toEqual([2, 2, 4, 3]);
unmountUiRoot(parent);
});
});
function ComposerValue({ store }: { store: ChatStateStore }): ComponentChild {
return useChatSelector(store, selectChatPanelComposer).draft;
}
function SelectorRegions({
store,
renders,
}: {
store: ChatStateStore;
renders: Record<"toolbar" | "goal" | "threadStream" | "composer", Mock<() => void>>;
}): ComponentChild {
return (
<>
<Region store={store} selector={selectChatPanelToolbar} rendered={renders.toolbar} />
<Region store={store} selector={selectChatPanelGoal} rendered={renders.goal} />
<Region store={store} selector={selectChatPanelThreadStream} rendered={renders.threadStream} />
<Region store={store} selector={selectChatPanelComposer} rendered={renders.composer} />
</>
);
}
function Region<Selection extends object>({
store,
selector,
rendered,
}: {
store: ChatStateStore;
selector: (state: ChatState) => Selection;
rendered: Mock<() => void>;
}): ComponentChild {
useChatSelector(store, selector);
rendered();
return null;
}
function regionRenderCounts(renders: {
toolbar: Mock<() => void>;
goal: Mock<() => void>;
threadStream: Mock<() => void>;
composer: Mock<() => void>;
}): number[] {
return [renders.toolbar, renders.goal, renders.threadStream, renders.composer].map((rendered) => rendered.mock.calls.length);
}
interface ControllableStore extends ChatStateStore {
listenerCount(): number;
replace(state: ChatState): void;
replaceWithoutNotification(state: ChatState): void;
}
function controllableStore(initialState: ChatState, onSubscribe?: (store: ControllableStore) => void): ControllableStore {
let state = initialState;
const listeners = new Set<() => void>();
const store: ControllableStore = {
getState: () => state,
dispatch: () => state,
subscribe(listener) {
listeners.add(listener);
onSubscribe?.(store);
return () => {
listeners.delete(listener);
};
},
listenerCount: () => listeners.size,
replace(nextState) {
state = nextState;
for (const listener of listeners) listener();
},
replaceWithoutNotification(nextState) {
state = nextState;
},
};
return store;
}

View file

@ -11,15 +11,16 @@ import type {
import type { NoteCandidateProvider } from "../../../../src/features/chat/application/composer/note-context";
import type { ChatStateStore } from "../../../../src/features/chat/application/state/store";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { threadStreamItems } from "../../../../src/features/chat/application/state/thread-stream";
import { pendingWebSubmissionItem } from "../../../../src/features/chat/application/turns/web-submission";
import type { ThreadStreamItem } from "../../../../src/features/chat/domain/thread-stream/items";
import { ChatComposerController, type ChatComposerRenderActions } from "../../../../src/features/chat/panel/composer-controller";
import type { ChatPanelComposerReadModel } from "../../../../src/features/chat/panel/shell-read-model";
import type { ChatPanelComposerModel } from "../../../../src/features/chat/panel/shell-selectors";
import { ComposerShell } from "../../../../src/features/chat/ui/composer";
import { renderUiRoot, unmountUiRoot } from "../../../../src/shared/dom/preact-root.dom";
import { deferred } from "../../../support/async";
import { installObsidianDomShims } from "../../../support/dom";
import { composerReadModelFromChatState, threadStreamReadModelFromChatState } from "../support/shell-read-model";
import { composerModelFromChatState } from "../support/shell-selectors";
import { chatStateFixture, chatStateWith } from "../support/state";
installObsidianDomShims();
@ -40,7 +41,7 @@ function renderComposerController(
actions: ChatComposerRenderActions = { submit: vi.fn() },
): void {
renderedComposerParents.add(parent);
renderUiRoot(parent, h(ComposerShell, controller.renderState(composerReadModelFromChatState(stateStore.getState()), actions)));
renderUiRoot(parent, h(ComposerShell, controller.renderState(composerModelFromChatState(stateStore.getState()), actions)));
}
function trackComposerControllerTestCleanup(cleanup: () => void): void {
@ -100,7 +101,7 @@ describe("ChatComposerController", () => {
},
});
const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() });
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
expect(props.submissionDisabled).toBe(true);
expect(props.webSubmissionCancellable).toBe(true);
@ -121,7 +122,7 @@ describe("ChatComposerController", () => {
},
} as never);
const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() });
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
expect(props.submissionDisabled).toBe(true);
expect(props.webSubmissionCancellable).toBe(false);
@ -153,20 +154,18 @@ describe("ChatComposerController", () => {
});
stateStore.dispatch({ type: "turn/completed", turnId: "turn", status: "completed", items: [assistant] });
const model = threadStreamReadModelFromChatState(stateStore.getState());
expect(model.items.value.map((item) => item.id)).toEqual(["assistant", pending.id]);
expect(model.stableItems.value.map((item) => item.id)).toEqual(["assistant", pending.id]);
expect(threadStreamItems(stateStore.getState().threadStream).map((item) => item.id)).toEqual(["assistant"]);
expect(stateStore.getState().pendingSubmission?.id).toBe(pending.id);
});
it("derives composer placeholder and meta from the projection", () => {
const projection = vi.fn((model: ChatPanelComposerReadModel) => ({
placeholder: `Projected ${model.draft.value || "empty"}`,
const projection = vi.fn((model: ChatPanelComposerModel) => ({
placeholder: `Projected ${model.draft || "empty"}`,
meta: defaultComposerProjection(model).meta,
}));
const { controller, stateStore } = composerControllerFixture({ controller: { composerProjection: projection } });
const props = controller.renderState(composerReadModelFromChatState(stateStore.getState()), { submit: vi.fn() });
const props = controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() });
expect(props.normalPlaceholder).toBe("Projected empty");
expect(props.meta.statusSummary).toBe(
@ -1064,7 +1063,7 @@ function skill(name: string): SkillMetadata {
};
}
function defaultComposerProjection(_model: ChatPanelComposerReadModel) {
function defaultComposerProjection(_model: ChatPanelComposerModel) {
return {
placeholder: "Ask Codex...",
meta: {

View file

@ -7,7 +7,7 @@ import type { NoteCandidateProvider } from "../../../../src/features/chat/applic
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller";
import { type ChatPanelShellParts, renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/panel/shell.dom";
import type { ChatPanelComposerReadModel } from "../../../../src/features/chat/panel/shell-read-model";
import type { ChatPanelComposerModel } from "../../../../src/features/chat/panel/shell-selectors";
import type { ChatPanelGoalSurface } from "../../../../src/features/chat/panel/surface/goal-projection";
import type { ChatPanelToolbarSurface } from "../../../../src/features/chat/panel/surface/toolbar-projection";
import { threadStreamViewBlocks } from "../../../../src/features/chat/presentation/thread-stream/view-model";
@ -69,7 +69,7 @@ describe("ChatPanelShell", () => {
});
});
it("keeps Tab wikilink insertion before closing brackets through shell signal updates", async () => {
it("keeps Tab wikilink insertion before closing brackets through shell selector updates", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
document.body.appendChild(container);
@ -293,14 +293,14 @@ function shellParts(
goal: surface.goal,
threadStream: {
renderState: (model) => {
void model.activeThreadCwd.value;
void model.activeThreadCwd;
return {
blocks: threadStreamViewBlocks({
activeThreadId: model.activeThreadId.value,
activeThreadId: model.activeThreadId,
activeTurnId: null,
historyCursor: model.historyCursor.value,
loadingHistory: model.loadingHistory.value,
items: model.items.value,
historyCursor: model.threadStream.historyCursor,
loadingHistory: model.threadStream.loadingHistory,
items: model.threadStream.stableItems,
}),
context: testThreadStreamContext,
scrollPortBinding: noOpThreadStreamScrollPortBinding,
@ -310,10 +310,10 @@ function shellParts(
composer: {
presenter: {
renderState: (model) => {
void model.runtimeSnapshot.value;
void model.runtime;
return {
viewId: "view",
draft: model.draft.value,
draft: model.draft,
busy: false,
canInterrupt: false,
submissionDisabled: false,
@ -393,7 +393,7 @@ function contextProvider(
};
}
function composerProjectionFixture(_model: ChatPanelComposerReadModel) {
function composerProjectionFixture(_model: ChatPanelComposerModel) {
return {
placeholder: "Ask Codex...",
meta: {

View file

@ -9,16 +9,26 @@ import { createServerDiagnostics } from "../../../../../src/domain/server/diagno
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
import type { Thread } from "../../../../../src/domain/threads/model";
import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
import { createChatPanelShellReadModelBinding } from "../../../../../src/features/chat/panel/shell-read-model";
import {
selectChatPanelComposer,
selectChatPanelGoal,
selectChatPanelThreadStream,
selectChatPanelToolbar,
} from "../../../../../src/features/chat/panel/shell-selectors";
import type { ChatPanelComposerProjectionActions } from "../../../../../src/features/chat/panel/surface/composer-projection";
import { chatPanelComposerProjection } from "../../../../../src/features/chat/panel/surface/composer-projection";
import { ChatPanelGoal, type ChatPanelGoalSurface } from "../../../../../src/features/chat/panel/surface/goal-projection";
import {
type ChatThreadStreamSurfaceContext,
threadStreamSurfaceProjectionFromModel,
} from "../../../../../src/features/chat/panel/surface/thread-stream-projection";
import { ChatPanelToolbar } from "../../../../../src/features/chat/panel/surface/toolbar-projection";
import type { ToolbarActions } from "../../../../../src/features/chat/ui/toolbar";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom";
import { installObsidianDomShims } from "../../../../support/dom";
import { composerReadModelFromChatState } from "../../support/shell-read-model";
import { composerModelFromChatState } from "../../support/shell-selectors";
import { chatStateFixture, chatStateWith } from "../../support/state";
import { withChatStateThreadStreamItems } from "../../support/thread-stream";
@ -44,8 +54,8 @@ describe("chat panel surface projections", () => {
ui: { toolbarPanel: "chat-actions" },
});
const actions = toolbarActionsFixture();
const parent = renderWithShellReadModel(state, (readModel) =>
h(ChatPanelToolbar, { model: readModel.toolbar, surface: toolbarSurfaceFixture(), actions }),
const parent = renderWithShellModels(state, (models) =>
h(ChatPanelToolbar, { model: models.toolbar, stateStore: createChatStateStore(state), surface: toolbarSurfaceFixture(), actions }),
);
const items = [...parent.querySelectorAll<HTMLElement>(".codex-panel__chat-actions-panel-item")];
@ -79,7 +89,8 @@ describe("chat panel surface projections", () => {
},
]);
expect(createChatPanelShellReadModelBinding(state).readModel.threadStream.rollbackCandidate.value).toBeNull();
const projection = threadStreamSurfaceProjectionFromModel(selectChatPanelThreadStream(state), threadStreamSurfaceContext());
expect(JSON.stringify(projection.blocks)).not.toContain('"rollback":true');
});
it("builds toolbar rows from immutable chat state snapshots", () => {
@ -97,9 +108,10 @@ describe("chat panel surface projections", () => {
});
state = chatStateWith(state, { connection: { serverDiagnostics: createServerDiagnostics() } });
const parent = renderWithShellReadModel(state, (readModel) =>
const parent = renderWithShellModels(state, (models) =>
h(ChatPanelToolbar, {
model: readModel.toolbar,
model: models.toolbar,
stateStore: createChatStateStore(state),
surface: toolbarSurfaceFixture({ archiveExportEnabled: true }),
actions: toolbarActionsFixture(),
}),
@ -128,15 +140,18 @@ describe("chat panel surface projections", () => {
});
state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-debug")] } });
state = chatStateWith(state, { runtime: { pending: { model: { kind: "set", value: "gpt-debug" } } } });
const stateStore = createChatStateStore(state);
const copyDebugDetails = vi.fn<(details: string) => void>();
const parent = renderWithShellReadModel(state, (readModel) =>
const parent = renderWithShellModels(state, (models) =>
h(ChatPanelToolbar, {
model: readModel.toolbar,
model: models.toolbar,
stateStore,
surface: toolbarSurfaceFixture(),
actions: toolbarActionsFixture({ copyDebugDetails }),
}),
);
stateStore.dispatch({ type: "runtime/model-requested", model: "gpt-live" });
parent.querySelectorAll<HTMLButtonElement>(".codex-panel__status-panel-item")[2]?.click();
const debugContent = copyDebugDetails.mock.calls[0]?.[0];
@ -162,7 +177,7 @@ describe("chat panel surface projections", () => {
(debugDetails["connection"] as { serverDiagnostics?: Record<string, unknown> }).serverDiagnostics?.["toolInventory"],
).toBeUndefined();
expect(debugDetails["runtimeConfig"]).toMatchObject({ model: "gpt-debug" });
expect(debugDetails["runtime"]).toMatchObject({ pending: { model: { kind: "set", value: "gpt-debug" } } });
expect(debugDetails["runtime"]).toMatchObject({ pending: { model: { kind: "set", value: "gpt-live" } } });
expect(debugDetails["runtimeLayers"]).toBeUndefined();
expect(debugDetails["runtimeResolution"]).toBeUndefined();
expect(debugDetails["availableModels"]).toMatchObject([{ model: "gpt-debug" }]);
@ -191,9 +206,10 @@ describe("chat panel surface projections", () => {
},
});
const parent = renderWithShellReadModel(state, (readModel) =>
const parent = renderWithShellModels(state, (models) =>
h(ChatPanelToolbar, {
model: readModel.toolbar,
model: models.toolbar,
stateStore: createChatStateStore(state),
surface: toolbarSurfaceFixture(),
actions: toolbarActionsFixture(),
}),
@ -414,13 +430,13 @@ describe("chat panel surface projections", () => {
},
} satisfies ChatPanelGoalSurface;
const parent = renderWithShellReadModel(state, (readModel) => h(ChatPanelGoal, { model: readModel.goal, surface }));
const parent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface }));
state = chatStateWith(state, { activeThread: { id: "thread-current" } });
clickLabeledButton(parent, "Pause goal");
clickLabeledButton(parent, "Clear goal");
state = chatStateWith(state, { activeThread: { goal: { ...goalFixture("thread-rendered"), status: "paused" } } });
const resumeParent = renderWithShellReadModel(state, (readModel) => h(ChatPanelGoal, { model: readModel.goal, surface }));
const resumeParent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface }));
clickLabeledButton(resumeParent, "Resume goal");
expect(statuses).toEqual([
@ -483,26 +499,61 @@ describe("chat panel surface projections", () => {
});
state = chatStateWith(state, { ui: { disclosures: { goalObjectiveExpanded: new Set(["thread-1"]) } } });
const parent = renderWithShellReadModel(state, (readModel) =>
h(ChatPanelGoal, { model: readModel.goal, surface: goalSurfaceFixture() }),
);
const parent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface: goalSurfaceFixture() }));
expect(parent.querySelector<HTMLTextAreaElement>(".codex-panel__goal-objective-input")?.value).toBe("Draft goal");
unmountUiRoot(parent);
});
});
function renderWithShellReadModel(
state: ChatState,
node: (readModel: ReturnType<typeof createChatPanelShellReadModelBinding>["readModel"]) => ComponentChild,
): HTMLElement {
function renderWithShellModels(state: ChatState, node: (models: ReturnType<typeof shellModelsFromState>) => ComponentChild): HTMLElement {
const parent = document.createElement("div");
renderUiRoot(parent, node(createChatPanelShellReadModelBinding(state).readModel));
renderUiRoot(parent, node(shellModelsFromState(state)));
return parent;
}
function shellModelsFromState(state: ChatState) {
return {
toolbar: selectChatPanelToolbar(state),
goal: selectChatPanelGoal(state),
threadStream: selectChatPanelThreadStream(state),
composer: selectChatPanelComposer(state),
};
}
function threadStreamSurfaceContext(): ChatThreadStreamSurfaceContext {
return {
panelId: "test-panel",
vaultPath: "/vault",
setDisclosureOpen: vi.fn(),
setForkMenuItem: vi.fn(),
loadOlderTurns: vi.fn(),
renderObsidianMarkdown: vi.fn(),
renderStreamMarkdown: vi.fn(),
copyDialogueText: vi.fn(),
actions: {
rollbackThread: vi.fn(),
forkThreadFromTurn: vi.fn(),
implementPlan: vi.fn(),
openThreadInNewView: vi.fn(),
openTurnDiff: vi.fn(),
},
requests: {
pendingActions: () => ({
resolveApproval: vi.fn(),
resolveUserInput: vi.fn(),
cancelUserInput: vi.fn(),
resolveMcpElicitation: vi.fn(),
setUserInputDraft: vi.fn(),
setMcpElicitationDraft: vi.fn(),
}),
consumePendingAutoFocus: () => false,
},
};
}
function composerProjectionFromState(actions: ChatPanelComposerProjectionActions, state: ChatState) {
return chatPanelComposerProjection(composerReadModelFromChatState(state), actions);
return chatPanelComposerProjection(composerModelFromChatState(state), actions);
}
function clickLabeledButton(parent: HTMLElement, label: string): void {

View file

@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { type ChatAction, type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer";
import { type ChatStateStore, createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { pendingWebSubmissionItem } from "../../../../../src/features/chat/application/turns/web-submission";
import { ThreadStreamPresenter } from "../../../../../src/features/chat/panel/surface/thread-stream-presenter";
import {
type ChatThreadStreamSurfaceContext,
@ -20,7 +21,7 @@ import { ThreadStreamViewport } from "../../../../../src/features/chat/ui/thread
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom";
import { notices } from "../../../../mocks/obsidian";
import { installObsidianDomShims } from "../../../../support/dom";
import { threadStreamReadModelFromChatState } from "../../support/shell-read-model";
import { threadStreamModelFromChatState } from "../../support/shell-selectors";
import { chatStateFixture, chatStateWith } from "../../support/state";
import { withChatStateThreadStreamItems } from "../../support/thread-stream";
import { installThreadStreamViewportMetrics, pendingApproval } from "../../ui/thread-stream/test-helpers";
@ -30,7 +31,7 @@ const ESTIMATED_THREAD_STREAM_BLOCK_HEIGHT = 96;
installObsidianDomShims();
function renderThreadStreamPresenter(parent: HTMLElement, presenter: ThreadStreamPresenter, state: ChatState): void {
renderUiRoot(parent, h(ThreadStreamViewport, { state: presenter.renderState(threadStreamReadModelFromChatState(state)) }));
renderUiRoot(parent, h(ThreadStreamViewport, { state: presenter.renderState(threadStreamModelFromChatState(state)) }));
}
describe("ThreadStreamPresenter scroll pinning", () => {
@ -65,7 +66,7 @@ describe("ThreadStreamPresenter scroll pinning", () => {
});
const projection = threadStreamSurfaceProjectionFromModel(
threadStreamReadModelFromChatState(store.getState()),
threadStreamModelFromChatState(store.getState()),
testThreadStreamSurfaceContext({
vaultPath: "/vault",
dispatch: (action) => {
@ -90,7 +91,7 @@ describe("ThreadStreamPresenter scroll pinning", () => {
},
});
const context = threadStreamSurfaceProjectionFromModel(threadStreamReadModelFromChatState(store.getState()), surfaceContext).context;
const context = threadStreamSurfaceProjectionFromModel(threadStreamModelFromChatState(store.getState()), surfaceContext).context;
if (!context.onDisclosureToggle) throw new Error("Expected thread stream disclosure action");
context.onDisclosureToggle("textDetails", "message:details", true);
@ -102,7 +103,7 @@ describe("ThreadStreamPresenter scroll pinning", () => {
state = withChatStateThreadStreamItems(state, [{ id: "system", kind: "system", role: "system", text: "Waiting for approval." }]);
state = chatStateWith(state, { requests: { approvals: [pendingApproval()] } });
const projection = threadStreamSurfaceProjectionFromModel(
threadStreamReadModelFromChatState(state),
threadStreamModelFromChatState(state),
testThreadStreamSurfaceContext({
vaultPath: "/vault",
dispatch: () => undefined,
@ -114,6 +115,41 @@ describe("ThreadStreamPresenter scroll pinning", () => {
expect(projection.context.pendingRequests?.snapshot().approvals).toHaveLength(1);
});
it("keeps a pending web submission visible after the canonical turn completes", () => {
const pending = pendingWebSubmissionItem("pending-web", "https://example.com", "Summarize");
if (!pending) throw new Error("Expected pending web submission item");
const store = createChatStateStore(
withChatStateThreadStreamItems(chatStateFixture(), [
{
id: "assistant",
kind: "dialogue",
dialogueKind: "assistantResponse",
dialogueState: "completed",
role: "assistant",
text: "Previous answer",
turnId: "turn",
},
]),
);
store.dispatch({
type: "web-submission/pending",
submission: {
id: pending.id,
item: pending,
targetThreadId: "thread",
originalDraft: "/web https://example.com Summarize",
phase: "cancellable",
},
});
const projection = threadStreamSurfaceProjectionFromModel(
threadStreamModelFromChatState(store.getState()),
testThreadStreamSurfaceContext({ vaultPath: "/vault", dispatch: () => undefined }),
);
expect(JSON.stringify(projection.blocks)).toContain(pending.id);
});
it("normalizes rendered internal links that point at absolute vault paths", async () => {
const openLinkText = vi.fn();
const context = markdownLinkContext(openLinkText, "/Users/showhey/Vault", ["docs/Guide.md"]);

View file

@ -1,10 +0,0 @@
import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer";
import { createChatPanelShellReadModelBinding } from "../../../../src/features/chat/panel/shell-read-model";
export function composerReadModelFromChatState(state: ChatState) {
return createChatPanelShellReadModelBinding(state).readModel.composer;
}
export function threadStreamReadModelFromChatState(state: ChatState) {
return createChatPanelShellReadModelBinding(state).readModel.threadStream;
}

View file

@ -0,0 +1,10 @@
import type { ChatState } from "../../../../src/features/chat/application/state/root-reducer";
import { selectChatPanelComposer, selectChatPanelThreadStream } from "../../../../src/features/chat/panel/shell-selectors";
export function composerModelFromChatState(state: ChatState) {
return selectChatPanelComposer(state);
}
export function threadStreamModelFromChatState(state: ChatState) {
return selectChatPanelThreadStream(state);
}

View file

@ -149,24 +149,6 @@ const policyCases = [
'import type { Store } from "../application/state/store";',
'import type { View } from "../presentation/thread-stream/view-model";',
),
policyCase(
"no-preact-signal-imports.grit",
"src/settings/escape.ts",
'import { signal } from "@preact/signals";',
"export const value = 1;",
),
policyCase(
"no-chat-shell-read-model-imports.grit",
"src/features/chat/presentation/escape.ts",
'import type { ReadModel } from "../panel/shell-read-model";',
"export type Value = string;",
),
policyCase(
"no-chat-signal-type-references.grit",
"src/features/chat/presentation/escape.ts",
"export type Value = Signal<string>;",
"export type Value = string;",
),
policyCase(
"no-state-module-side-effects.grit",
"src/features/chat/application/state/escape.ts",