Add state ports for chat controllers

This commit is contained in:
murashit 2026-05-31 20:48:31 +09:00
parent 5adb721c18
commit d1f5e84aa0
29 changed files with 406 additions and 211 deletions

View file

@ -24,6 +24,14 @@ import { ChatConnectionController } from "./controllers/connection/connection-co
import { ChatReconnectController } from "./controllers/connection/reconnect-controller";
import { PendingRequestController } from "./controllers/requests/pending-request-controller";
import { ServerRequestResponder } from "./controllers/requests/server-request-responder";
import {
createChatShellRenderPort,
createConnectionStatePort,
createPanelUiStatePort,
createPendingRequestStatePort,
createSubmissionStatePort,
createThreadLifecycleStatePort,
} from "./controllers/state-ports";
import { ComposerSubmissionController } from "./controllers/submission/composer-submission-controller";
import { PlanImplementationController } from "./controllers/submission/plan-implementation-controller";
import { SlashCommandController } from "./controllers/submission/slash-command-controller";
@ -137,13 +145,18 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
let planImplementation!: PlanImplementationController;
let threadSelection!: ThreadSelectionController;
let serverRequestResponder!: ServerRequestResponder;
const connectionState = createConnectionStatePort(host.stateStore);
const panelState = createPanelUiStatePort(host.stateStore);
const submissionState = createSubmissionStatePort(host.stateStore);
const threadState = createThreadLifecycleStatePort(host.stateStore);
renderController = new ChatViewRenderController({
stateStore: host.stateStore,
shell: createChatShellRenderPort(host.stateStore, {
connected: () => connection.isConnected(),
pendingRequestsSignature: host.pendingRequestsSignature,
activeComposerThreadName: host.activeComposerThreadName,
}),
panelRoot: host.panelRoot,
connected: () => connection.isConnected(),
pendingRequestsSignature: host.pendingRequestsSignature,
activeComposerThreadName: host.activeComposerThreadName,
renderToolbar: host.renderToolbar,
renderMessages: host.renderMessages,
renderComposer: host.renderComposer,
@ -152,7 +165,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
},
});
turnSubmission = new TurnSubmissionController({
stateStore: host.stateStore,
state: submissionState,
vaultPath: host.plugin.vaultPath,
currentClient: host.getClient,
ensureRestoredThreadLoaded: host.ensureRestoredThreadLoaded,
@ -171,7 +184,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
addSystemMessage: host.effects.status.addSystemMessage,
});
slashCommands = new SlashCommandController({
stateStore: host.stateStore,
state: submissionState,
currentClient: host.getClient,
codexInput: (text) => composerController.codexInput(text),
startNewThread: host.startNewThread,
@ -229,7 +242,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
},
});
composerSubmission = new ComposerSubmissionController({
stateStore: host.stateStore,
state: submissionState,
composer: composerController,
slashCommands,
turnSubmission,
@ -242,7 +255,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
currentClient: host.getClient,
});
planImplementation = new PlanImplementationController({
stateStore: host.stateStore,
state: submissionState,
currentClient: host.getClient,
ensureConnected: host.effects.client.ensureConnected,
sendTurnText: (text) => turnSubmission.sendTurnText(text),
@ -329,7 +342,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
rejectServerRequest: (requestId, code, message) => serverRequestResponder.reject(requestId, code, message),
});
pendingRequests = new PendingRequestController({
stateStore: host.stateStore,
state: createPendingRequestStatePort(host.stateStore),
controller,
composerHasFocus: () => composerController.hasFocus(),
refreshLiveState: host.effects.liveState.refresh,
@ -349,7 +362,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
},
});
connectionController = new ChatConnectionController({
stateStore: host.stateStore,
state: connectionState,
connection,
connectionWork: host.connectionWork,
appServer,
@ -408,7 +421,8 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
scheduleRender: host.effects.render.schedule,
});
threadSelection = new ThreadSelectionController({
stateStore: host.stateStore,
panelState,
threadState,
closeForThreadSelection: () => {
toolbarPanels.closeForThreadSelection();
},
@ -417,8 +431,9 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
addSystemMessage: host.effects.status.addSystemMessage,
});
reconnectActions = new ChatReconnectController({
stateStore: host.stateStore,
activeThreadId: () => host.getState().activeThreadId,
connectionState,
panelState,
threadState,
invalidateConnectionWork: host.effects.lifecycle.invalidateConnectionWork,
invalidateResumeWork: host.effects.lifecycle.invalidateResumeWork,
clearDeferredDiagnostics: host.effects.lifecycle.clearDeferredDiagnostics,
@ -444,7 +459,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
opened: host.getOpened,
resumeThread: host.resumeThread,
invalidateResumeWork: host.effects.lifecycle.invalidateResumeWork,
dispatch: host.effects.state.dispatch,
state: threadState,
systemItem: host.effects.state.systemItem,
setStatus: host.effects.status.set,
refreshTabHeader: host.effects.thread.refreshTabHeader,
@ -457,7 +472,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
restoreThreadPlaceholder: host.effects.thread.restorePlaceholder,
});
threadResume = new ThreadResumeController({
stateStore: host.stateStore,
state: threadState,
vaultPath: host.plugin.vaultPath,
resumeWork: host.resumeWork,
history,
@ -475,7 +490,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl
refreshLiveState: host.effects.liveState.refresh,
});
threadIdentity = new ThreadIdentityController({
stateStore: host.stateStore,
state: threadState,
restoredThread,
invalidateResumeWork: host.effects.lifecycle.invalidateResumeWork,
clearDeferredRestoredThreadHydration: host.effects.lifecycle.clearDeferredRestoredThreadHydration,

View file

@ -1,9 +1,9 @@
import { StaleConnectionError } from "../../../../app-server/connection-manager";
import type { AppServerClient } from "../../../../app-server/client";
import type { InitializeResponse } from "../../../../generated/app-server/InitializeResponse";
import type { ChatAction, ChatStateStore } from "../../chat-state";
import type { ChatAppServerController } from "../../chat-app-server-controller";
import type { ChatConnectionWorkTracker, ActiveChatConnection } from "../../view-lifecycle";
import type { ConnectionStatePort } from "../state-ports";
export interface ChatConnectionAdapter {
connect(): Promise<InitializeResponse>;
@ -12,7 +12,7 @@ export interface ChatConnectionAdapter {
}
export interface ChatConnectionControllerHost {
stateStore: ChatStateStore;
state: ConnectionStatePort;
connection: ChatConnectionAdapter;
connectionWork: ChatConnectionWorkTracker;
appServer: ChatAppServerController;
@ -62,7 +62,7 @@ export class ChatConnectionController {
this.invalidate();
this.host.invalidateResumeWork();
this.host.setStatus("Codex app-server stopped.");
this.dispatch({ type: "connection/scoped-cleared" });
this.host.state.clearConnectionScope();
this.host.resetThreadTurnPresence(false);
this.host.setClient(null);
this.host.refreshLiveState();
@ -110,7 +110,7 @@ export class ChatConnectionController {
private async initializeConnection(connection: ActiveChatConnection): Promise<void> {
this.host.setStatus("Starting Codex app-server...");
try {
this.dispatch({ type: "connection/initialized", initializeResponse: await this.host.connection.connect() });
this.host.state.connectionInitialized(await this.host.connection.connect());
if (this.host.connectionWork.isStale(connection)) return;
const client = this.host.connection.currentClient();
this.host.setClient(client);
@ -133,10 +133,6 @@ export class ChatConnectionController {
this.host.scheduleRender();
}
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
}
function connectionErrorMessage(error: unknown, configuredCommand: string): string {

View file

@ -1,8 +1,9 @@
import type { ChatAction, ChatStateStore } from "../../chat-state";
import type { ConnectionStatePort, PanelUiStatePort, ThreadLifecycleStatePort } from "../state-ports";
export interface ChatReconnectControllerHost {
stateStore: ChatStateStore;
activeThreadId: () => string | null;
connectionState: ConnectionStatePort;
panelState: PanelUiStatePort;
threadState: ThreadLifecycleStatePort;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredDiagnostics: () => void;
@ -19,14 +20,14 @@ export class ChatReconnectController {
constructor(private readonly host: ChatReconnectControllerHost) {}
async reconnectFromToolbar(): Promise<void> {
const threadId = this.host.activeThreadId();
this.dispatch({ type: "ui/panel-set", panel: null });
const threadId = this.host.threadState.activeThreadId();
this.host.panelState.closePanels();
this.host.invalidateConnectionWork();
this.host.invalidateResumeWork();
this.host.clearDeferredDiagnostics();
this.host.reconnect();
this.host.clearClient();
this.dispatch({ type: "turn/local-cleared" });
this.host.connectionState.clearLocalTurn();
this.host.setStatus("Reconnecting...");
this.host.render();
@ -38,8 +39,4 @@ export class ChatReconnectController {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
}

View file

@ -1,15 +1,15 @@
import type { ComponentChild as ReactNode } from "preact";
import type { ApprovalAction, PendingApproval } from "../../approvals/model";
import type { ChatAction, ChatState, ChatStateStore } from "../../chat-state";
import type { ChatController } from "../../chat-controller";
import { pendingRequestFocusSignature } from "../../request-state";
import { pendingRequestMessageNode } from "../../ui/pending-request-message";
import { userInputDraftKey, userInputOtherDraftKey } from "../../user-input/drafts";
import { answersForPendingUserInput, type PendingUserInput } from "../../user-input/model";
import type { PendingRequestStatePort } from "../state-ports";
export interface PendingRequestControllerHost {
stateStore: ChatStateStore;
state: PendingRequestStatePort;
controller: ChatController;
composerHasFocus: () => boolean;
refreshLiveState: () => void;
@ -21,24 +21,17 @@ export class PendingRequestController {
constructor(private readonly host: PendingRequestControllerHost) {}
private get state(): ChatState {
return this.host.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
renderNode(): ReactNode {
const state = this.host.state.snapshot();
return pendingRequestMessageNode(
this.state.approvals,
this.state.pendingUserInputs,
state.approvals,
state.pendingUserInputs,
{
values: this.state.userInputDrafts,
values: state.userInputDrafts,
draftKey: userInputDraftKey,
otherDraftKey: userInputOtherDraftKey,
},
this.state.openDetails,
state.openDetails,
{
resolveApproval: (approval, action) => {
this.resolveApproval(approval, action);
@ -50,10 +43,10 @@ export class PendingRequestController {
this.cancelUserInput(input);
},
setOpenDetail: (key, open) => {
this.dispatch({ type: "ui/detail-open-set", key, open });
this.host.state.setDetailOpen(key, open);
},
setUserInputDraft: (key, value) => {
this.dispatch({ type: "request/user-input-draft-set", key, value });
this.host.state.setUserInputDraft(key, value);
},
},
this.consumeAutoFocus(),
@ -66,7 +59,7 @@ export class PendingRequestController {
}
resolveUserInput(input: PendingUserInput): void {
this.host.controller.resolveUserInput(input, answersForPendingUserInput(input, this.state.userInputDrafts));
this.host.controller.resolveUserInput(input, answersForPendingUserInput(input, this.host.state.snapshot().userInputDrafts));
this.commitRequestAction();
}
@ -81,7 +74,8 @@ export class PendingRequestController {
}
private consumeAutoFocus(): boolean {
const signature = pendingRequestFocusSignature(this.state.approvals, this.state.pendingUserInputs);
const state = this.host.state.snapshot();
const signature = pendingRequestFocusSignature(state.approvals, state.pendingUserInputs);
if (!signature) {
this.lastFocusSignature = "";
return false;

View file

@ -0,0 +1,213 @@
import type { InitializeResponse } from "../../../generated/app-server/InitializeResponse";
import type { Thread } from "../../../generated/app-server/v2/Thread";
import { activeTurnId, chatTurnBusy, pendingTurnStart, type ChatStateStore, type PendingTurnStart } from "../chat-state";
import type { PendingApproval } from "../approvals/model";
import type { PendingUserInput } from "../user-input/model";
import type { DisplayItem } from "../display/types";
import { implementPlanCandidateFromState } from "../plan-implementation";
import { resumedThreadAction, type ThreadActivationResponse } from "../thread-resume";
import { composerSlotSnapshot, messagesSlotSnapshot, toolbarSlotSnapshot } from "../view-snapshot";
import { renderChatPanelShell } from "../ui/shell";
export interface ConnectionStatePort {
connectionInitialized(initializeResponse: InitializeResponse): void;
clearConnectionScope(): void;
clearLocalTurn(): void;
}
export interface PanelUiStatePort {
closePanels(): void;
pinMessagesToBottom(): void;
}
export interface PendingRequestSnapshot {
approvals: readonly PendingApproval[];
pendingUserInputs: readonly PendingUserInput[];
userInputDrafts: ReadonlyMap<string, string>;
openDetails: ReadonlySet<string>;
}
export interface PendingRequestStatePort {
snapshot(): PendingRequestSnapshot;
setDetailOpen(key: string, open: boolean): void;
setUserInputDraft(key: string, value: string): void;
}
export interface ThreadLifecycleStatePort {
activeThreadId(): string | null;
canSwitchToThread(threadId: string): boolean;
listedThreads: readonly Thread[];
clearActiveThread(): void;
applyThreadList(threads: readonly Thread[]): void;
restorePlaceholder(threadId: string, item: DisplayItem): void;
displayItemsEmpty(): boolean;
applyResumedThread(response: ThreadActivationResponse, displayItems: readonly DisplayItem[]): void;
}
export interface SubmissionStateSnapshot {
activeThreadId: string | null;
activeTurnId: string | null;
busy: boolean;
listedThreads: readonly Thread[];
displayItems: readonly DisplayItem[];
pendingTurnStart: PendingTurnStart | null;
}
export interface SubmissionStatePort {
snapshot(): SubmissionStateSnapshot;
canImplementPlan(item: DisplayItem): boolean;
prepareImplementationTurn(): void;
optimisticTurnStarted(item: DisplayItem, pendingStart: PendingTurnStart): void;
turnStartAcknowledged(turnId: string, displayItems: readonly DisplayItem[]): void;
turnStartFailed(displayItems: readonly DisplayItem[]): void;
addLocalUserMessage(item: DisplayItem): void;
}
export interface ChatShellRenderPort {
render(
root: HTMLElement,
renderVersion: number,
slots: {
renderToolbar: (toolbar: HTMLElement) => void;
renderMessages: (parent: HTMLElement) => void;
renderComposer: (parent: HTMLElement) => void;
},
): void;
}
export function createConnectionStatePort(stateStore: ChatStateStore): ConnectionStatePort {
return {
connectionInitialized(initializeResponse) {
stateStore.dispatch({ type: "connection/initialized", initializeResponse });
},
clearConnectionScope() {
stateStore.dispatch({ type: "connection/scoped-cleared" });
},
clearLocalTurn() {
stateStore.dispatch({ type: "turn/local-cleared" });
},
};
}
export function createPanelUiStatePort(stateStore: ChatStateStore): PanelUiStatePort {
return {
closePanels() {
stateStore.dispatch({ type: "ui/panel-set", panel: null });
},
pinMessagesToBottom() {
stateStore.dispatch({ type: "ui/messages-pinned-set", pinned: true });
},
};
}
export function createPendingRequestStatePort(stateStore: ChatStateStore): PendingRequestStatePort {
return {
snapshot() {
const state = stateStore.getState();
return {
approvals: state.approvals,
pendingUserInputs: state.pendingUserInputs,
userInputDrafts: state.userInputDrafts,
openDetails: state.openDetails,
};
},
setDetailOpen(key, open) {
stateStore.dispatch({ type: "ui/detail-open-set", key, open });
},
setUserInputDraft(key, value) {
stateStore.dispatch({ type: "request/user-input-draft-set", key, value });
},
};
}
export function createThreadLifecycleStatePort(stateStore: ChatStateStore): ThreadLifecycleStatePort {
return {
activeThreadId: () => stateStore.getState().activeThreadId,
canSwitchToThread(threadId) {
const state = stateStore.getState();
return !chatTurnBusy(state) || threadId === state.activeThreadId;
},
get listedThreads() {
return stateStore.getState().listedThreads;
},
clearActiveThread() {
stateStore.dispatch({ type: "thread/active-cleared" });
},
applyThreadList(threads) {
stateStore.dispatch({ type: "thread/list-applied", threads });
},
restorePlaceholder(threadId, item) {
stateStore.dispatch({ type: "thread/restored-placeholder", threadId, item });
},
displayItemsEmpty: () => stateStore.getState().displayItems.length === 0,
applyResumedThread(response, displayItems) {
stateStore.dispatch(
resumedThreadAction({
response,
listedThreads: stateStore.getState().listedThreads,
displayItems,
}),
);
},
};
}
export function createSubmissionStatePort(stateStore: ChatStateStore): SubmissionStatePort {
return {
snapshot() {
const state = stateStore.getState();
return {
activeThreadId: state.activeThreadId,
activeTurnId: activeTurnId(state),
busy: chatTurnBusy(state),
listedThreads: state.listedThreads,
displayItems: state.displayItems,
pendingTurnStart: pendingTurnStart(state),
};
},
canImplementPlan: (item) => item.id === implementPlanCandidateFromState(stateStore.getState())?.id,
prepareImplementationTurn() {
stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
stateStore.dispatch({ type: "ui/panel-set", panel: null });
},
optimisticTurnStarted(item, pendingStart) {
stateStore.dispatch({ type: "turn/optimistic-started", item, pendingTurnStart: pendingStart });
},
turnStartAcknowledged(turnId, displayItems) {
stateStore.dispatch({ type: "turn/start-acknowledged", turnId, displayItems });
},
turnStartFailed(displayItems) {
stateStore.dispatch({ type: "turn/start-failed", displayItems });
},
addLocalUserMessage(item) {
stateStore.dispatch({ type: "system/message-added", item });
},
};
}
export function createChatShellRenderPort(
stateStore: ChatStateStore,
options: {
connected: () => boolean;
pendingRequestsSignature: () => string;
activeComposerThreadName: () => string | null;
},
): ChatShellRenderPort {
return {
render(root, renderVersion, slots) {
renderChatPanelShell(root, {
stateStore,
renderVersion,
toolbar: { render: slots.renderToolbar, snapshot: (state) => toolbarSlotSnapshot(state, options.connected()) },
messages: {
render: slots.renderMessages,
snapshot: (state) => messagesSlotSnapshot(state, options.pendingRequestsSignature()),
},
composer: {
render: slots.renderComposer,
snapshot: (state) => composerSlotSnapshot(state, options.activeComposerThreadName()),
},
});
},
};
}

View file

@ -1,12 +1,12 @@
import type { AppServerClient } from "../../../../app-server/client";
import { parseSlashCommand } from "../../composer/suggestions";
import { activeTurnId, chatTurnBusy, type ChatStateStore } from "../../chat-state";
import type { ChatComposerController } from "../../chat-composer-controller";
import type { SlashCommandController } from "./slash-command-controller";
import type { TurnSubmissionController } from "./turn-submission-controller";
import type { SubmissionStatePort } from "../state-ports";
export interface ComposerSubmissionControllerHost {
stateStore: ChatStateStore;
state: SubmissionStatePort;
composer: ChatComposerController;
slashCommands: SlashCommandController;
turnSubmission: TurnSubmissionController;
@ -21,8 +21,8 @@ export class ComposerSubmissionController {
async submit(): Promise<void> {
const draft = this.host.composer.trimmedDraft;
const state = this.host.stateStore.getState();
if (chatTurnBusy(state) && state.activeThreadId && activeTurnId(state) && draft.length === 0) {
const state = this.host.state.snapshot();
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
await this.interruptTurn();
return;
}
@ -50,8 +50,8 @@ export class ComposerSubmissionController {
}
private async interruptTurn(): Promise<void> {
const state = this.host.stateStore.getState();
const turnId = activeTurnId(state);
const state = this.host.state.snapshot();
const turnId = state.activeTurnId;
const client = this.host.currentClient();
if (!client || !state.activeThreadId || !turnId) return;
try {

View file

@ -1,12 +1,11 @@
import type { AppServerClient } from "../../../../app-server/client";
import type { ChatState, ChatStateStore } from "../../chat-state";
import type { DisplayItem } from "../../display/types";
import { implementPlanCandidateFromState } from "../../plan-implementation";
import type { SubmissionStatePort } from "../state-ports";
const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
export interface PlanImplementationControllerHost {
stateStore: ChatStateStore;
state: SubmissionStatePort;
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
sendTurnText: (text: string) => Promise<void>;
@ -16,20 +15,15 @@ export class PlanImplementationController {
constructor(private readonly host: PlanImplementationControllerHost) {}
canImplement(item: DisplayItem): boolean {
return item.id === implementPlanCandidateFromState(this.state)?.id;
return this.host.state.canImplementPlan(item);
}
async implement(item: DisplayItem): Promise<void> {
if (!this.canImplement(item)) return;
await this.host.ensureConnected();
if (!this.host.currentClient() || !this.state.activeThreadId) return;
if (!this.host.currentClient() || !this.host.state.snapshot().activeThreadId) return;
this.host.stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
this.host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
this.host.state.prepareImplementationTurn();
await this.host.sendTurnText(IMPLEMENT_PLAN_PROMPT);
}
private get state(): ChatState {
return this.host.stateStore.getState();
}
}

View file

@ -5,15 +5,15 @@ import {
REFERENCED_THREAD_TURN_LIMIT,
} from "../../../../domain/threads/reference";
import type { Thread } from "../../../../generated/app-server/v2/Thread";
import { chatTurnBusy, type ChatStateStore } from "../../chat-state";
import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult, type ThreadReferenceInput } from "../../slash-commands";
import type { SlashCommandName } from "../../composer/slash-commands";
import type { DisplayDetailSection } from "../../display/types";
import type { ReasoningEffort } from "../../../../generated/app-server/ReasoningEffort";
import type { UserInput } from "../../../../generated/app-server/v2/UserInput";
import type { SubmissionStatePort } from "../state-ports";
export interface SlashCommandControllerHost {
stateStore: ChatStateStore;
state: SubmissionStatePort;
currentClient: () => AppServerClient | null;
codexInput: (text: string) => UserInput[];
startNewThread: () => Promise<void>;
@ -42,7 +42,7 @@ export class SlashCommandController {
async execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined> {
const client = this.host.currentClient();
if (!client) return;
const state = this.host.stateStore.getState();
const state = this.host.state.snapshot();
return runSlashCommand(command, args, {
activeThreadId: state.activeThreadId,
listedThreads: state.listedThreads,
@ -55,7 +55,7 @@ export class SlashCommandController {
await client.compactThread(threadId);
},
archiveThread: (threadId) => this.host.archiveThread(threadId),
busy: chatTurnBusy(state),
busy: state.busy,
toggleFastMode: () => this.host.toggleFastMode(),
toggleCollaborationMode: () => this.host.toggleCollaborationMode(),
toggleAutoReview: () => this.host.toggleAutoReview(),

View file

@ -1,6 +1,5 @@
import type { AppServerClient } from "../../../../app-server/client";
import type { UserInput } from "../../../../generated/app-server/v2/UserInput";
import { activeTurnId, chatTurnBusy, pendingTurnStart, type ChatStateStore } from "../../chat-state";
import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference";
import {
acknowledgeOptimisticTurnStart,
@ -9,9 +8,10 @@ import {
optimisticTurnStart,
shouldAcknowledgeTurnStart,
} from "./turn-submission";
import type { SubmissionStatePort } from "../state-ports";
export interface TurnSubmissionControllerHost {
stateStore: ChatStateStore;
state: SubmissionStatePort;
vaultPath: string;
currentClient: () => AppServerClient | null;
ensureRestoredThreadLoaded: () => Promise<boolean>;
@ -36,7 +36,7 @@ export class TurnSubmissionController {
const client = this.host.currentClient();
if (!client) return;
if (chatTurnBusy(this.state)) {
if (this.state.busy) {
await this.steerCurrentTurn(client, text, codexInputOverride, referencedThread);
return;
}
@ -61,21 +61,17 @@ export class TurnSubmissionController {
codexInput,
referencedThread,
});
this.host.stateStore.dispatch({
type: "turn/optimistic-started",
item: optimistic.item,
pendingTurnStart: optimistic.pendingTurnStart,
});
this.host.state.optimisticTurnStarted(optimistic.item, optimistic.pendingTurnStart);
this.host.forceMessagesToBottom();
this.host.setDraft("");
this.host.render();
const response = await client.startTurn(activeThreadId, this.host.vaultPath, codexInput);
const pendingStart = pendingTurnStart(this.state);
const pendingStart = this.state.pendingTurnStart;
if (
shouldAcknowledgeTurnStart({
pendingTurnStart: pendingStart,
activeTurnId: activeTurnIdForState(this.host.stateStore),
activeTurnId: this.state.activeTurnId,
optimisticUserId,
responseTurnId: response.turn.id,
})
@ -86,16 +82,16 @@ export class TurnSubmissionController {
turnId: response.turn.id,
pendingTurnStart: pendingStart,
});
this.host.stateStore.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, displayItems });
this.host.state.turnStartAcknowledged(response.turn.id, displayItems);
this.host.setStatus("Turn running...");
}
} catch (error) {
const displayItems = cleanupFailedTurnStart({
items: this.state.displayItems,
optimisticUserId,
pendingTurnStart: pendingTurnStart(this.state),
pendingTurnStart: this.state.pendingTurnStart,
});
this.host.stateStore.dispatch({ type: "turn/start-failed", displayItems });
this.host.state.turnStartFailed(displayItems);
this.host.setDraft(text);
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
@ -109,7 +105,7 @@ export class TurnSubmissionController {
referencedThread?: ReferencedThreadDisplay,
): Promise<void> {
const threadId = this.state.activeThreadId;
const expectedTurnId = activeTurnIdForState(this.host.stateStore);
const expectedTurnId = this.state.activeTurnId;
if (!threadId || !expectedTurnId) {
this.host.addSystemMessage("Current turn is not steerable yet.");
return;
@ -120,16 +116,15 @@ export class TurnSubmissionController {
try {
await client.steerTurn(threadId, expectedTurnId, codexInput);
this.host.stateStore.dispatch({
type: "system/message-added",
item: localUserMessageItemFromInput({
this.host.state.addLocalUserMessage(
localUserMessageItemFromInput({
id: `local-steer-${String(Date.now())}`,
text,
turnId: expectedTurnId,
referencedThread,
codexInput,
}),
});
);
this.host.forceMessagesToBottom();
this.host.setStatus("Steered current turn.");
} catch (error) {
@ -141,10 +136,6 @@ export class TurnSubmissionController {
}
private get state() {
return this.host.stateStore.getState();
return this.host.state.snapshot();
}
}
function activeTurnIdForState(stateStore: ChatStateStore): string | null {
return activeTurnId(stateStore.getState());
}

View file

@ -1,5 +1,5 @@
import type { ChatAction } from "../../chat-state";
import type { DisplayItem } from "../../display/types";
import type { ThreadLifecycleStatePort } from "../state-ports";
import {
transitionRestoredThreadLifecycle,
type RestoredThreadLifecycleState,
@ -13,7 +13,7 @@ export interface RestoredThreadControllerHost {
opened: () => boolean;
resumeThread: (threadId: string) => Promise<void>;
invalidateResumeWork: () => void;
dispatch: (action: ChatAction) => void;
state: ThreadLifecycleStatePort;
systemItem: (text: string) => DisplayItem;
setStatus: (status: string) => void;
refreshTabHeader: () => void;
@ -48,11 +48,7 @@ export class RestoredThreadController {
type: "placeholder-restored",
restoredThread,
});
this.host.dispatch({
type: "thread/restored-placeholder",
threadId: restoredThread.threadId,
item: this.host.systemItem("Thread restored. Send a message to resume it."),
});
this.host.state.restorePlaceholder(restoredThread.threadId, this.host.systemItem("Thread restored. Send a message to resume it."));
this.host.setStatus("Thread ready to resume.");
this.host.refreshTabHeader();
this.scheduleHydration();

View file

@ -1,8 +1,8 @@
import type { ChatStateStore } from "../../chat-state";
import type { RestoredThreadController } from "./restored-thread-controller";
import type { ThreadLifecycleStatePort } from "../state-ports";
export interface ThreadIdentityControllerHost {
stateStore: ChatStateStore;
state: ThreadLifecycleStatePort;
restoredThread: RestoredThreadController;
invalidateResumeWork: () => void;
clearDeferredRestoredThreadHydration: () => void;
@ -20,32 +20,32 @@ export class ThreadIdentityController {
this.host.invalidateResumeWork();
this.host.restoredThread.clear();
this.host.clearDeferredRestoredThreadHydration();
this.host.stateStore.dispatch({ type: "thread/active-cleared" });
this.host.state.clearActiveThread();
this.host.resetThreadTurnPresence(false);
this.host.notifyActiveThreadIdentityChanged();
this.host.refreshLiveState();
}
notifyThreadArchived(threadId: string): void {
if (this.state.activeThreadId !== threadId) return;
if (this.host.state.activeThreadId() !== threadId) return;
this.clearActiveThreadContext();
this.host.render();
}
notifyThreadRenamed(threadId: string, name: string | null): void {
let changed = false;
const listedThreads = this.state.listedThreads.map((thread) => {
const listedThreads = this.host.state.listedThreads.map((thread) => {
if (thread.id !== threadId) return thread;
changed = true;
return { ...thread, name };
});
this.host.stateStore.dispatch({ type: "thread/list-applied", threads: listedThreads });
this.host.state.applyThreadList(listedThreads);
const restoredThread = this.host.restoredThread.placeholder();
if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) {
this.host.restoredThread.rename(threadId, name);
changed = true;
}
const activeThreadChanged = this.state.activeThreadId === threadId || this.host.restoredThread.isPending(threadId);
const activeThreadChanged = this.host.state.activeThreadId() === threadId || this.host.restoredThread.isPending(threadId);
if (!changed && !activeThreadChanged) return;
if (activeThreadChanged) {
this.host.notifyActiveThreadIdentityChanged();
@ -54,8 +54,4 @@ export class ThreadIdentityController {
}
this.host.render();
}
private get state() {
return this.host.stateStore.getState();
}
}

View file

@ -1,14 +1,13 @@
import type { AppServerClient } from "../../../../app-server/client";
import type { ChatStateStore } from "../../chat-state";
import { chatTurnBusy } from "../../chat-state";
import type { DisplayItem } from "../../display/types";
import type { RestoredThreadController } from "./restored-thread-controller";
import { resumedThreadAction, type ThreadActivationResponse } from "../../thread-resume";
import type { ThreadActivationResponse } from "../../thread-resume";
import type { ThreadHistoryLoader } from "../../thread-history";
import type { ChatResumeWorkTracker, ActiveChatResume } from "../../view-lifecycle";
import type { ThreadLifecycleStatePort } from "../state-ports";
export interface ThreadResumeControllerHost {
stateStore: ChatStateStore;
state: ThreadLifecycleStatePort;
vaultPath: string;
resumeWork: ChatResumeWorkTracker;
history: ThreadHistoryLoader;
@ -30,8 +29,7 @@ export class ThreadResumeController {
constructor(private readonly host: ThreadResumeControllerHost) {}
async resumeThread(threadId: string): Promise<void> {
const state = this.host.stateStore.getState();
if (chatTurnBusy(state) && threadId !== state.activeThreadId) {
if (!this.host.state.canSwitchToThread(threadId)) {
this.host.addSystemMessage("Finish or interrupt the current turn before switching threads.");
return;
}
@ -46,7 +44,7 @@ export class ThreadResumeController {
this.applyResumedThread(response);
await this.host.history.loadLatest(response.thread.id);
if (this.isStale(resume)) return;
if (this.host.stateStore.getState().displayItems.length === 0) {
if (this.host.state.displayItemsEmpty()) {
this.host.addSystemMessage(`Resumed thread ${response.thread.id}`);
this.host.forceMessagesToBottom();
this.host.render();
@ -59,13 +57,7 @@ export class ThreadResumeController {
}
private applyResumedThread(response: ThreadActivationResponse): void {
this.host.stateStore.dispatch(
resumedThreadAction({
response,
listedThreads: this.host.stateStore.getState().listedThreads,
displayItems: [this.host.systemItem("Loading thread...")],
}),
);
this.host.state.applyResumedThread(response, [this.host.systemItem("Loading thread...")]);
this.host.restoredThread.clear();
this.host.clearDeferredRestoredThreadHydration();
this.host.resetThreadTurnPresence(false);

View file

@ -1,7 +1,8 @@
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../../chat-state";
import type { PanelUiStatePort, ThreadLifecycleStatePort } from "../state-ports";
export interface ThreadSelectionControllerHost {
stateStore: ChatStateStore;
panelState: PanelUiStatePort;
threadState: ThreadLifecycleStatePort;
closeForThreadSelection: () => void;
focusThreadInOpenView: (threadId: string) => Promise<boolean>;
resumeThread: (threadId: string) => Promise<void>;
@ -12,7 +13,7 @@ export class ThreadSelectionController {
constructor(private readonly host: ThreadSelectionControllerHost) {}
async selectThread(threadId: string): Promise<void> {
if (chatTurnBusy(this.state) && threadId !== this.state.activeThreadId) {
if (!this.host.threadState.canSwitchToThread(threadId)) {
this.host.addSystemMessage("Finish or interrupt the current turn before switching threads.");
return;
}
@ -23,17 +24,9 @@ export class ThreadSelectionController {
}
async selectThreadFromToolbar(threadId: string): Promise<void> {
if (chatTurnBusy(this.state) && threadId !== this.state.activeThreadId) return;
if (!this.host.threadState.canSwitchToThread(threadId)) return;
this.dispatch({ type: "ui/panel-set", panel: null });
this.host.panelState.closePanels();
await this.selectThread(threadId);
}
private get state(): ChatState {
return this.host.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
}

View file

@ -1,8 +1,8 @@
import type { ChatStateStore } from "../../chat-state";
import type { MessageScrollIntent } from "../../ui/scroll";
import type { PanelUiStatePort } from "../state-ports";
export interface ChatMessageScrollControllerHost {
stateStore: ChatStateStore;
state: PanelUiStatePort;
render: () => void;
}
@ -18,7 +18,7 @@ export class ChatMessageScrollController {
}
forceBottom(): void {
this.host.stateStore.dispatch({ type: "ui/messages-pinned-set", pinned: true });
this.host.state.pinMessagesToBottom();
this.nextIntent = "force-bottom";
}

View file

@ -1,14 +1,9 @@
import type { ChatState, ChatStateStore } from "../../chat-state";
import type { ChatViewRenderScheduleOptions } from "../../view-lifecycle";
import { composerSlotSnapshot, messagesSlotSnapshot, toolbarSlotSnapshot } from "../../view-snapshot";
import { renderChatPanelShell } from "../../ui/shell";
import type { ChatShellRenderPort } from "../state-ports";
export interface ChatViewRenderControllerHost {
stateStore: ChatStateStore;
shell: ChatShellRenderPort;
panelRoot: () => HTMLElement | null;
connected: () => boolean;
pendingRequestsSignature: () => string;
activeComposerThreadName: () => string | null;
renderToolbar: (toolbar: HTMLElement) => void;
renderMessages: (parent: HTMLElement) => void;
renderComposer: (parent: HTMLElement) => void;
@ -25,12 +20,10 @@ export class ChatViewRenderController {
const root = this.host.panelRoot();
if (!root) return;
if (options.forceSlots) this.shellRenderVersion += 1;
renderChatPanelShell(root, {
stateStore: this.host.stateStore,
renderVersion: this.shellRenderVersion,
toolbar: { render: this.renderToolbarSlot, snapshot: this.toolbarSnapshot },
messages: { render: this.renderMessagesSlot, snapshot: this.messagesSnapshot },
composer: { render: this.renderComposerSlot, snapshot: this.composerSnapshot },
this.host.shell.render(root, this.shellRenderVersion, {
renderToolbar: this.renderToolbarSlot,
renderMessages: this.renderMessagesSlot,
renderComposer: this.renderComposerSlot,
});
}
@ -49,10 +42,4 @@ export class ChatViewRenderController {
private readonly renderComposerSlot = (parent: HTMLElement): void => {
this.host.renderComposer(parent);
};
private readonly toolbarSnapshot = (state: ChatState) => toolbarSlotSnapshot(state, this.host.connected());
private readonly messagesSnapshot = (state: ChatState) => messagesSlotSnapshot(state, this.host.pendingRequestsSignature());
private readonly composerSnapshot = (state: ChatState) => composerSlotSnapshot(state, this.host.activeComposerThreadName());
}

View file

@ -40,6 +40,7 @@ import {
import { ChatMessageScrollController } from "./controllers/view/message-scroll-controller";
import { createChatViewEffects, type ChatViewEffects } from "./view-effects";
import { createChatViewControllerAssembly, type ChatViewControllerAssembly } from "./chat-view-controller-assembly";
import { createPanelUiStatePort } from "./controllers/state-ports";
export type { CodexChatHost } from "./chat-host";
@ -87,7 +88,7 @@ export class CodexChatView extends ItemView {
this.history.invalidate();
});
this.messageScroll = new ChatMessageScrollController({
stateStore: this.chatState,
state: createPanelUiStatePort(this.chatState),
render: () => {
this.render();
},

View file

@ -6,6 +6,7 @@ import {
ChatConnectionController,
type ChatConnectionAdapter,
} from "../../../../../src/features/chat/controllers/connection/connection-controller";
import { createConnectionStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import { ChatConnectionWorkTracker } from "../../../../../src/features/chat/view-lifecycle";
import type { ChatAppServerController } from "../../../../../src/features/chat/chat-app-server-controller";
@ -33,7 +34,7 @@ function createController({ connected = false, client = {} as AppServerClient }
currentClient = next;
});
const host = {
stateStore,
state: createConnectionStatePort(stateStore),
connection,
connectionWork: new ChatConnectionWorkTracker(),
appServer,

View file

@ -5,13 +5,30 @@ import {
ChatReconnectController,
type ChatReconnectControllerHost,
} from "../../../../../src/features/chat/controllers/connection/reconnect-controller";
import {
createConnectionStatePort,
createPanelUiStatePort,
createThreadLifecycleStatePort,
} from "../../../../../src/features/chat/controllers/state-ports";
function createHost(overrides: Partial<ChatReconnectControllerHost> = {}) {
const stateStore = createChatStateStore(createChatState());
stateStore.dispatch({ type: "ui/panel-set", panel: "history" });
stateStore.dispatch({
type: "thread/resumed",
thread: { id: "thread" } as never,
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalPolicy: null,
approvalsReviewer: null,
activePermissionProfile: null,
});
const host: ChatReconnectControllerHost = {
stateStore,
activeThreadId: () => "thread",
connectionState: createConnectionStatePort(stateStore),
panelState: createPanelUiStatePort(stateStore),
threadState: createThreadLifecycleStatePort(stateStore),
invalidateConnectionWork: vi.fn(),
invalidateResumeWork: vi.fn(),
clearDeferredDiagnostics: vi.fn(),

View file

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { ChatController } from "../../../../../src/features/chat/chat-controller";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/chat-state";
import { createPendingRequestStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import { PendingRequestController } from "../../../../../src/features/chat/controllers/requests/pending-request-controller";
import { toPendingUserInput } from "../../../../../src/features/chat/user-input/model";
import type { ServerRequest } from "../../../../../src/generated/app-server/ServerRequest";
@ -29,7 +30,7 @@ describe("PendingRequestController", () => {
rejectServerRequest: vi.fn(),
});
const pendingRequests = new PendingRequestController({
stateStore,
state: createPendingRequestStatePort(stateStore),
controller,
composerHasFocus: () => false,
refreshLiveState,

View file

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../../src/app-server/client";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/chat-state";
import { ComposerSubmissionController } from "../../../../../src/features/chat/controllers/submission/composer-submission-controller";
import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import type { ChatComposerController } from "../../../../../src/features/chat/chat-composer-controller";
import type { SlashCommandController } from "../../../../../src/features/chat/controllers/submission/slash-command-controller";
import type { TurnSubmissionController } from "../../../../../src/features/chat/controllers/submission/turn-submission-controller";
@ -40,7 +41,7 @@ function createController(draft: string) {
const sendTurnText = vi.fn().mockResolvedValue(undefined);
const execute = vi.fn().mockResolvedValue(undefined);
const controller = new ComposerSubmissionController({
stateStore,
state: createSubmissionStatePort(stateStore),
composer: {
get trimmedDraft() {
return draft;

View file

@ -7,6 +7,7 @@ import {
PlanImplementationController,
type PlanImplementationControllerHost,
} from "../../../../../src/features/chat/controllers/submission/plan-implementation-controller";
import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import type { DisplayItem } from "../../../../../src/features/chat/display/types";
const planItem = (id: string): DisplayItem => ({
@ -36,7 +37,7 @@ function resumeThread(stateStore: ChatStateStore, displayItems: readonly Display
function createController({ client = {} as AppServerClient } = {}) {
const stateStore = createChatStateStore(createChatState());
const host: PlanImplementationControllerHost = {
stateStore,
state: createSubmissionStatePort(stateStore),
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
sendTurnText: vi.fn().mockResolvedValue(undefined),

View file

@ -6,6 +6,7 @@ import {
SlashCommandController,
type SlashCommandControllerHost,
} from "../../../../../src/features/chat/controllers/submission/slash-command-controller";
import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import type { Thread } from "../../../../../src/generated/app-server/v2/Thread";
import type { UserInput } from "../../../../../src/generated/app-server/v2/UserInput";
@ -41,7 +42,7 @@ function createHost(overrides: Partial<SlashCommandControllerHost> = {}) {
const threadTurnsList = vi.fn().mockResolvedValue({ data: [] });
const client = { compactThread, threadTurnsList } as unknown as AppServerClient;
const host: SlashCommandControllerHost = {
stateStore,
state: createSubmissionStatePort(stateStore),
currentClient: () => client,
codexInput: vi.fn((text: string) => textInput(text)),
startNewThread: vi.fn().mockResolvedValue(undefined),

View file

@ -6,6 +6,7 @@ import {
TurnSubmissionController,
type TurnSubmissionControllerHost,
} from "../../../../../src/features/chat/controllers/submission/turn-submission-controller";
import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import type { Thread } from "../../../../../src/generated/app-server/v2/Thread";
import type { UserInput } from "../../../../../src/generated/app-server/v2/UserInput";
@ -44,7 +45,7 @@ function createHost(overrides: Partial<TurnSubmissionControllerHost> = {}) {
steerTurn,
} as unknown as AppServerClient;
const host: TurnSubmissionControllerHost = {
stateStore,
state: createSubmissionStatePort(stateStore),
vaultPath: "/vault",
currentClient: () => client,
ensureRestoredThreadLoaded: vi.fn().mockResolvedValue(true),

View file

@ -2,21 +2,21 @@
import { describe, expect, it, vi } from "vitest";
import type { ThreadLifecycleStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import { RestoredThreadController } from "../../../../../src/features/chat/controllers/thread/restored-thread-controller";
import { ChatViewDeferredTasks } from "../../../../../src/features/chat/view-lifecycle";
import type { ChatAction } from "../../../../../src/features/chat/chat-state";
describe("RestoredThreadController", () => {
it("restores a placeholder and schedules deferred hydration", () => {
vi.useFakeTimers();
const actions: ChatAction[] = [];
const restorePlaceholder = vi.fn();
const resumeThread = vi.fn().mockResolvedValue(undefined);
const controller = new RestoredThreadController({
deferredTasks: new ChatViewDeferredTasks(() => window),
opened: () => true,
resumeThread,
invalidateResumeWork: vi.fn(),
dispatch: (action) => actions.push(action),
state: restoredThreadState({ restorePlaceholder }),
systemItem: (text) => ({ id: "system", kind: "system", role: "system", text }),
setStatus: vi.fn(),
refreshTabHeader: vi.fn(),
@ -25,7 +25,7 @@ describe("RestoredThreadController", () => {
controller.restore({ threadId: "thread", title: "Title", explicitName: null });
expect(controller.placeholder()).toMatchObject({ threadId: "thread", title: "Title" });
expect(actions).toHaveLength(1);
expect(restorePlaceholder).toHaveBeenCalledOnce();
vi.advanceTimersByTime(1_500);
expect(resumeThread).toHaveBeenCalledWith("thread");
vi.useRealTimers();
@ -63,7 +63,7 @@ function restoredThreadControllerFixture(overrides: Partial<ConstructorParameter
opened: () => false,
resumeThread: vi.fn().mockResolvedValue(undefined),
invalidateResumeWork: vi.fn(),
dispatch: vi.fn(),
state: restoredThreadState(),
systemItem: (text) => ({ id: "system", kind: "system", role: "system", text }),
setStatus: vi.fn(),
refreshTabHeader: vi.fn(),
@ -71,6 +71,20 @@ function restoredThreadControllerFixture(overrides: Partial<ConstructorParameter
});
}
function restoredThreadState(overrides: Partial<ThreadLifecycleStatePort> = {}): ThreadLifecycleStatePort {
return {
activeThreadId: () => null,
canSwitchToThread: () => true,
listedThreads: [],
clearActiveThread: vi.fn(),
applyThreadList: vi.fn(),
restorePlaceholder: vi.fn(),
displayItemsEmpty: () => true,
applyResumedThread: vi.fn(),
...overrides,
};
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/chat-state";
import { createThreadLifecycleStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import { ThreadIdentityController } from "../../../../../src/features/chat/controllers/thread/thread-identity-controller";
import type { RestoredThreadController } from "../../../../../src/features/chat/controllers/thread/restored-thread-controller";
import type { RestoredThreadPlaceholderState } from "../../../../../src/features/chat/view-lifecycle";
@ -41,7 +42,7 @@ function createController() {
rename: restoredRename,
} as unknown as RestoredThreadController;
const host = {
stateStore,
state: createThreadLifecycleStatePort(stateStore),
restoredThread,
invalidateResumeWork: vi.fn(),
clearDeferredRestoredThreadHydration: vi.fn(),

View file

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../../src/app-server/client";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/chat-state";
import type { RestoredThreadController } from "../../../../../src/features/chat/controllers/thread/restored-thread-controller";
import { createThreadLifecycleStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import type { ThreadActivationResponse } from "../../../../../src/features/chat/thread-resume";
import { ThreadResumeController } from "../../../../../src/features/chat/controllers/thread/thread-resume-controller";
import type { ThreadHistoryLoader } from "../../../../../src/features/chat/thread-history";
@ -53,7 +54,7 @@ function createController() {
const loadLatest = vi.fn().mockResolvedValue(undefined);
const restoredClear = vi.fn();
const host = {
stateStore,
state: createThreadLifecycleStatePort(stateStore),
vaultPath: "/vault",
resumeWork: new ChatResumeWorkTracker(() => undefined),
history: { loadLatest } as unknown as ThreadHistoryLoader,

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/chat-state";
import { createPanelUiStatePort, createThreadLifecycleStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import {
ThreadSelectionController,
type ThreadSelectionControllerHost,
@ -23,7 +24,8 @@ function resumeThreadState(stateStore: ChatStateStore, threadId: string): void {
function createController(overrides: Partial<ThreadSelectionControllerHost> = {}) {
const stateStore = createChatStateStore(createChatState());
const host: ThreadSelectionControllerHost = {
stateStore,
panelState: createPanelUiStatePort(stateStore),
threadState: createThreadLifecycleStatePort(stateStore),
closeForThreadSelection: vi.fn(),
focusThreadInOpenView: vi.fn().mockResolvedValue(false),
resumeThread: vi.fn().mockResolvedValue(undefined),

View file

@ -1,12 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/chat-state";
import { createPanelUiStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import { ChatMessageScrollController } from "../../../../../src/features/chat/controllers/view/message-scroll-controller";
describe("ChatMessageScrollController", () => {
it("consumes one-shot scroll intents", () => {
const controller = new ChatMessageScrollController({
stateStore: createChatStateStore(createChatState()),
state: createPanelUiStatePort(createChatStateStore(createChatState())),
render: vi.fn(),
});
@ -20,7 +21,7 @@ describe("ChatMessageScrollController", () => {
const stateStore = createChatStateStore(createChatState());
stateStore.dispatch({ type: "ui/messages-pinned-set", pinned: false });
const render = vi.fn();
const controller = new ChatMessageScrollController({ stateStore, render });
const controller = new ChatMessageScrollController({ state: createPanelUiStatePort(stateStore), render });
controller.scrollToBottomOnFocus();

View file

@ -1,28 +1,15 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/chat-state";
import type { ChatShellRenderPort } from "../../../../../src/features/chat/controllers/state-ports";
import { ChatViewRenderController } from "../../../../../src/features/chat/controllers/view/view-render-controller";
import { renderChatPanelShell } from "../../../../../src/features/chat/ui/shell";
vi.mock("../../../../../src/features/chat/ui/shell", () => ({
renderChatPanelShell: vi.fn(),
}));
describe("ChatViewRenderController", () => {
beforeEach(() => {
vi.mocked(renderChatPanelShell).mockClear();
});
it("renders the shell with stable slot delegates", () => {
const root = document.createElement("div");
const root = {} as HTMLElement;
const render = vi.fn<ChatShellRenderPort["render"]>();
const controller = new ChatViewRenderController({
stateStore: createChatStateStore(createChatState()),
shell: { render },
panelRoot: () => root,
connected: () => true,
pendingRequestsSignature: () => "",
activeComposerThreadName: () => null,
renderToolbar: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
@ -31,25 +18,26 @@ describe("ChatViewRenderController", () => {
controller.render();
expect(renderChatPanelShell).toHaveBeenCalledWith(
expect(render).toHaveBeenCalledWith(
root,
0,
expect.objectContaining({
renderVersion: 0,
toolbar: expect.objectContaining({ render: expect.any(Function), snapshot: expect.any(Function) }),
messages: expect.objectContaining({ render: expect.any(Function), snapshot: expect.any(Function) }),
composer: expect.objectContaining({ render: expect.any(Function), snapshot: expect.any(Function) }),
renderToolbar: expect.any(Function),
renderMessages: expect.any(Function),
renderComposer: expect.any(Function),
}),
);
});
it("increments the render version when forcing slot rerender", () => {
const root = document.createElement("div");
const root = {} as HTMLElement;
const renderVersions: number[] = [];
const render: ChatShellRenderPort["render"] = vi.fn((_root: HTMLElement, renderVersion: number) => {
renderVersions.push(renderVersion);
});
const controller = new ChatViewRenderController({
stateStore: createChatStateStore(createChatState()),
shell: { render },
panelRoot: () => root,
connected: () => true,
pendingRequestsSignature: () => "",
activeComposerThreadName: () => null,
renderToolbar: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
@ -59,6 +47,6 @@ describe("ChatViewRenderController", () => {
controller.render();
controller.renderShellSlots();
expect(vi.mocked(renderChatPanelShell).mock.calls.map(([, model]) => model.renderVersion)).toEqual([0, 1]);
expect(renderVersions).toEqual([0, 1]);
});
});