diff --git a/src/features/chat/chat-view-controller-assembly.ts b/src/features/chat/chat-view-controller-assembly.ts index 64dae139..53e7cd93 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -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, diff --git a/src/features/chat/controllers/connection/connection-controller.ts b/src/features/chat/controllers/connection/connection-controller.ts index ffaea04c..b5c14ab6 100644 --- a/src/features/chat/controllers/connection/connection-controller.ts +++ b/src/features/chat/controllers/connection/connection-controller.ts @@ -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; @@ -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 { 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 { diff --git a/src/features/chat/controllers/connection/reconnect-controller.ts b/src/features/chat/controllers/connection/reconnect-controller.ts index 5cda62d2..1718311f 100644 --- a/src/features/chat/controllers/connection/reconnect-controller.ts +++ b/src/features/chat/controllers/connection/reconnect-controller.ts @@ -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 { - 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); - } } diff --git a/src/features/chat/controllers/requests/pending-request-controller.ts b/src/features/chat/controllers/requests/pending-request-controller.ts index d12143b1..c190afbf 100644 --- a/src/features/chat/controllers/requests/pending-request-controller.ts +++ b/src/features/chat/controllers/requests/pending-request-controller.ts @@ -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; diff --git a/src/features/chat/controllers/state-ports.ts b/src/features/chat/controllers/state-ports.ts new file mode 100644 index 00000000..50796af4 --- /dev/null +++ b/src/features/chat/controllers/state-ports.ts @@ -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; + openDetails: ReadonlySet; +} + +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()), + }, + }); + }, + }; +} diff --git a/src/features/chat/controllers/submission/composer-submission-controller.ts b/src/features/chat/controllers/submission/composer-submission-controller.ts index ef82aa38..aade1619 100644 --- a/src/features/chat/controllers/submission/composer-submission-controller.ts +++ b/src/features/chat/controllers/submission/composer-submission-controller.ts @@ -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 { 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 { - 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 { diff --git a/src/features/chat/controllers/submission/plan-implementation-controller.ts b/src/features/chat/controllers/submission/plan-implementation-controller.ts index 4724bd26..0e7a331b 100644 --- a/src/features/chat/controllers/submission/plan-implementation-controller.ts +++ b/src/features/chat/controllers/submission/plan-implementation-controller.ts @@ -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; sendTurnText: (text: string) => Promise; @@ -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 { 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(); - } } diff --git a/src/features/chat/controllers/submission/slash-command-controller.ts b/src/features/chat/controllers/submission/slash-command-controller.ts index db4ccbc8..15cf1676 100644 --- a/src/features/chat/controllers/submission/slash-command-controller.ts +++ b/src/features/chat/controllers/submission/slash-command-controller.ts @@ -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; @@ -42,7 +42,7 @@ export class SlashCommandController { async execute(command: SlashCommandName, args: string): Promise { 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(), diff --git a/src/features/chat/controllers/submission/turn-submission-controller.ts b/src/features/chat/controllers/submission/turn-submission-controller.ts index 27b4d04c..1996dd78 100644 --- a/src/features/chat/controllers/submission/turn-submission-controller.ts +++ b/src/features/chat/controllers/submission/turn-submission-controller.ts @@ -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; @@ -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 { 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()); -} diff --git a/src/features/chat/controllers/thread/restored-thread-controller.ts b/src/features/chat/controllers/thread/restored-thread-controller.ts index 21a735d6..aea2a85f 100644 --- a/src/features/chat/controllers/thread/restored-thread-controller.ts +++ b/src/features/chat/controllers/thread/restored-thread-controller.ts @@ -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; 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(); diff --git a/src/features/chat/controllers/thread/thread-identity-controller.ts b/src/features/chat/controllers/thread/thread-identity-controller.ts index 33f5713e..e137790f 100644 --- a/src/features/chat/controllers/thread/thread-identity-controller.ts +++ b/src/features/chat/controllers/thread/thread-identity-controller.ts @@ -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(); - } } diff --git a/src/features/chat/controllers/thread/thread-resume-controller.ts b/src/features/chat/controllers/thread/thread-resume-controller.ts index bffdc976..c6074eb4 100644 --- a/src/features/chat/controllers/thread/thread-resume-controller.ts +++ b/src/features/chat/controllers/thread/thread-resume-controller.ts @@ -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 { - 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); diff --git a/src/features/chat/controllers/thread/thread-selection-controller.ts b/src/features/chat/controllers/thread/thread-selection-controller.ts index c828795e..a231e696 100644 --- a/src/features/chat/controllers/thread/thread-selection-controller.ts +++ b/src/features/chat/controllers/thread/thread-selection-controller.ts @@ -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; resumeThread: (threadId: string) => Promise; @@ -12,7 +13,7 @@ export class ThreadSelectionController { constructor(private readonly host: ThreadSelectionControllerHost) {} async selectThread(threadId: string): Promise { - 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 { - 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); - } } diff --git a/src/features/chat/controllers/view/message-scroll-controller.ts b/src/features/chat/controllers/view/message-scroll-controller.ts index 5329d770..97a46b0b 100644 --- a/src/features/chat/controllers/view/message-scroll-controller.ts +++ b/src/features/chat/controllers/view/message-scroll-controller.ts @@ -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"; } diff --git a/src/features/chat/controllers/view/view-render-controller.ts b/src/features/chat/controllers/view/view-render-controller.ts index a72b9829..e6016c51 100644 --- a/src/features/chat/controllers/view/view-render-controller.ts +++ b/src/features/chat/controllers/view/view-render-controller.ts @@ -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()); } diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 5cc4503b..15593486 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -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(); }, diff --git a/tests/features/chat/controllers/connection/connection-controller.test.ts b/tests/features/chat/controllers/connection/connection-controller.test.ts index fd9ce432..89379ef2 100644 --- a/tests/features/chat/controllers/connection/connection-controller.test.ts +++ b/tests/features/chat/controllers/connection/connection-controller.test.ts @@ -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, diff --git a/tests/features/chat/controllers/connection/reconnect-controller.test.ts b/tests/features/chat/controllers/connection/reconnect-controller.test.ts index fb7612fc..3e0a3fb2 100644 --- a/tests/features/chat/controllers/connection/reconnect-controller.test.ts +++ b/tests/features/chat/controllers/connection/reconnect-controller.test.ts @@ -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 = {}) { 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(), diff --git a/tests/features/chat/controllers/requests/pending-request-controller.test.ts b/tests/features/chat/controllers/requests/pending-request-controller.test.ts index 00d27586..ce20db55 100644 --- a/tests/features/chat/controllers/requests/pending-request-controller.test.ts +++ b/tests/features/chat/controllers/requests/pending-request-controller.test.ts @@ -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, diff --git a/tests/features/chat/controllers/submission/composer-submission-controller.test.ts b/tests/features/chat/controllers/submission/composer-submission-controller.test.ts index 6a0ba24e..34f2d16a 100644 --- a/tests/features/chat/controllers/submission/composer-submission-controller.test.ts +++ b/tests/features/chat/controllers/submission/composer-submission-controller.test.ts @@ -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; diff --git a/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts b/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts index 67999b35..5d5a31df 100644 --- a/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts +++ b/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts @@ -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), diff --git a/tests/features/chat/controllers/submission/slash-command-controller.test.ts b/tests/features/chat/controllers/submission/slash-command-controller.test.ts index 62d3a027..c0ec7f7d 100644 --- a/tests/features/chat/controllers/submission/slash-command-controller.test.ts +++ b/tests/features/chat/controllers/submission/slash-command-controller.test.ts @@ -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 = {}) { 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), diff --git a/tests/features/chat/controllers/submission/turn-submission-controller.test.ts b/tests/features/chat/controllers/submission/turn-submission-controller.test.ts index fb4fcd85..85f6bfbb 100644 --- a/tests/features/chat/controllers/submission/turn-submission-controller.test.ts +++ b/tests/features/chat/controllers/submission/turn-submission-controller.test.ts @@ -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 = {}) { steerTurn, } as unknown as AppServerClient; const host: TurnSubmissionControllerHost = { - stateStore, + state: createSubmissionStatePort(stateStore), vaultPath: "/vault", currentClient: () => client, ensureRestoredThreadLoaded: vi.fn().mockResolvedValue(true), diff --git a/tests/features/chat/controllers/thread/restored-thread-controller.test.ts b/tests/features/chat/controllers/thread/restored-thread-controller.test.ts index 2398466d..522618a3 100644 --- a/tests/features/chat/controllers/thread/restored-thread-controller.test.ts +++ b/tests/features/chat/controllers/thread/restored-thread-controller.test.ts @@ -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 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 = {}): ThreadLifecycleStatePort { + return { + activeThreadId: () => null, + canSwitchToThread: () => true, + listedThreads: [], + clearActiveThread: vi.fn(), + applyThreadList: vi.fn(), + restorePlaceholder: vi.fn(), + displayItemsEmpty: () => true, + applyResumedThread: vi.fn(), + ...overrides, + }; +} + function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void; const promise = new Promise((promiseResolve) => { diff --git a/tests/features/chat/controllers/thread/thread-identity-controller.test.ts b/tests/features/chat/controllers/thread/thread-identity-controller.test.ts index 456942c6..711a408b 100644 --- a/tests/features/chat/controllers/thread/thread-identity-controller.test.ts +++ b/tests/features/chat/controllers/thread/thread-identity-controller.test.ts @@ -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(), diff --git a/tests/features/chat/controllers/thread/thread-resume-controller.test.ts b/tests/features/chat/controllers/thread/thread-resume-controller.test.ts index 0f5e2c97..3af3cc1d 100644 --- a/tests/features/chat/controllers/thread/thread-resume-controller.test.ts +++ b/tests/features/chat/controllers/thread/thread-resume-controller.test.ts @@ -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, diff --git a/tests/features/chat/controllers/thread/thread-selection-controller.test.ts b/tests/features/chat/controllers/thread/thread-selection-controller.test.ts index 1597a94b..98d36c1f 100644 --- a/tests/features/chat/controllers/thread/thread-selection-controller.test.ts +++ b/tests/features/chat/controllers/thread/thread-selection-controller.test.ts @@ -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 = {}) { 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), diff --git a/tests/features/chat/controllers/view/message-scroll-controller.test.ts b/tests/features/chat/controllers/view/message-scroll-controller.test.ts index 7f986697..f99f2bc6 100644 --- a/tests/features/chat/controllers/view/message-scroll-controller.test.ts +++ b/tests/features/chat/controllers/view/message-scroll-controller.test.ts @@ -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(); diff --git a/tests/features/chat/controllers/view/view-render-controller.test.ts b/tests/features/chat/controllers/view/view-render-controller.test.ts index 0c74e225..5b0f42de 100644 --- a/tests/features/chat/controllers/view/view-render-controller.test.ts +++ b/tests/features/chat/controllers/view/view-render-controller.test.ts @@ -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(); 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]); }); });