mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(chat): replace signal mirror with selectors
This commit is contained in:
parent
359bf1c21e
commit
a97f80e25e
26 changed files with 778 additions and 702 deletions
26
biome.jsonc
26
biome.jsonc
|
|
@ -131,32 +131,6 @@
|
|||
},
|
||||
|
||||
// Chat state and runtime ownership.
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-preact-signal-imports.grit",
|
||||
"includes": [
|
||||
"**/src/**/*.{ts,tsx}",
|
||||
"!**/src/features/chat/panel/shell-read-model.ts",
|
||||
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit",
|
||||
"includes": [
|
||||
"**/src/features/chat/**/*.{ts,tsx}",
|
||||
"!**/src/features/chat/panel/shell-read-model.ts",
|
||||
"!**/src/features/chat/panel/shell.dom.tsx",
|
||||
"!**/src/features/chat/panel/composer-controller.ts",
|
||||
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/import-boundaries/no-chat-signal-type-references.grit",
|
||||
"includes": [
|
||||
"**/src/features/chat/**/*.{ts,tsx}",
|
||||
"!**/src/features/chat/panel/shell-read-model.ts",
|
||||
"!**/src/features/chat/panel/surface/**/*.{ts,tsx}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "./scripts/grit/runtime/no-state-module-side-effects.grit",
|
||||
"includes": ["**/src/features/chat/application/state/**/*.ts"]
|
||||
|
|
|
|||
|
|
@ -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
27
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
75
src/features/chat/panel/chat-state-selector.ts
Normal file
75
src/features/chat/panel/chat-state-selector.ts
Normal 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]));
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
151
src/features/chat/panel/shell-selectors.ts
Normal file
151
src/features/chat/panel/shell-selectors.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
196
tests/features/chat/panel/chat-state-selector.test.tsx
Normal file
196
tests/features/chat/panel/chat-state-selector.test.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
10
tests/features/chat/support/shell-selectors.ts
Normal file
10
tests/features/chat/support/shell-selectors.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue