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