diff --git a/src/features/chat/chat-state-actions.ts b/src/features/chat/chat-state-actions.ts index 0a1d7a16..bcdaa9e6 100644 --- a/src/features/chat/chat-state-actions.ts +++ b/src/features/chat/chat-state-actions.ts @@ -1,7 +1,7 @@ import type { InitializeResponse } from "../../generated/app-server/InitializeResponse"; import type { Thread } from "../../generated/app-server/v2/Thread"; import type { ThreadTokenUsage } from "../../generated/app-server/v2/ThreadTokenUsage"; -import type { ChatAction } from "./chat-state"; +import type { ChatAction, PendingTurnStart } from "./chat-state"; import type { DisplayItem } from "./display/types"; export function connectionInitializedAction(initializeResponse: InitializeResponse): ChatAction { @@ -16,6 +16,10 @@ export function clearLocalTurnAction(): ChatAction { return { type: "turn/scoped-cleared" }; } +export function setRequestedCollaborationModeDefaultAction(): ChatAction { + return { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" }; +} + export function clearActiveThreadAction(): ChatAction { return { type: "active-thread/cleared" }; } @@ -47,3 +51,19 @@ export function setDetailOpenAction(key: string, open: boolean): ChatAction { export function setUserInputDraftAction(key: string, value: string): ChatAction { return { type: "request/user-input-draft-set", key, value }; } + +export function optimisticTurnStartedAction(item: DisplayItem, pendingTurnStart: PendingTurnStart): ChatAction { + return { type: "turn/optimistic-started", item, pendingTurnStart }; +} + +export function turnStartAcknowledgedAction(turnId: string, displayItems: readonly DisplayItem[]): ChatAction { + return { type: "turn/start-acknowledged", turnId, displayItems }; +} + +export function turnStartFailedAction(displayItems: readonly DisplayItem[]): ChatAction { + return { type: "turn/start-failed", displayItems }; +} + +export function addLocalUserMessageAction(item: DisplayItem): ChatAction { + return { type: "transcript/system-message-added", item }; +} diff --git a/src/features/chat/chat-state-selectors.ts b/src/features/chat/chat-state-selectors.ts index 879fec8b..9c4bb61e 100644 --- a/src/features/chat/chat-state-selectors.ts +++ b/src/features/chat/chat-state-selectors.ts @@ -1,8 +1,10 @@ -import { chatTurnBusy } from "./chat-state"; -import type { ChatState } from "./chat-state"; +import { activeTurnId as selectActiveTurnId, chatTurnBusy, pendingTurnStart } from "./chat-state"; +import type { ChatState, PendingTurnStart } from "./chat-state"; import type { Thread } from "../../generated/app-server/v2/Thread"; import type { PendingApproval } from "./requests/approvals/model"; import type { PendingUserInput } from "./requests/user-input/model"; +import type { DisplayItem } from "./display/types"; +import { implementPlanCandidateFromState } from "./plan-implementation"; export interface PendingRequestSnapshot { approvals: readonly PendingApproval[]; @@ -11,6 +13,15 @@ export interface PendingRequestSnapshot { openDetails: ReadonlySet; } +export interface SubmissionStateSnapshot { + activeThreadId: string | null; + activeTurnId: string | null; + busy: boolean; + listedThreads: readonly Thread[]; + displayItems: readonly DisplayItem[]; + pendingTurnStart: PendingTurnStart | null; +} + export function pendingRequestSnapshot(state: ChatState): PendingRequestSnapshot { return { approvals: state.requests.approvals, @@ -35,3 +46,18 @@ export function listedThreads(state: ChatState): readonly Thread[] { export function displayItemsEmpty(state: ChatState): boolean { return state.transcript.displayItems.length === 0; } + +export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapshot { + return { + activeThreadId: state.activeThread.id, + activeTurnId: selectActiveTurnId(state), + busy: chatTurnBusy(state), + listedThreads: state.threadList.listedThreads, + displayItems: state.transcript.displayItems, + pendingTurnStart: pendingTurnStart(state), + }; +} + +export function canImplementPlan(state: ChatState, item: DisplayItem): boolean { + return item.id === implementPlanCandidateFromState(state)?.id; +} diff --git a/src/features/chat/controllers/state-ports.ts b/src/features/chat/controllers/state-ports.ts index f7d79b92..83638125 100644 --- a/src/features/chat/controllers/state-ports.ts +++ b/src/features/chat/controllers/state-ports.ts @@ -1,29 +1,7 @@ -import type { Thread } from "../../../generated/app-server/v2/Thread"; -import { activeTurnId, chatTurnBusy, pendingTurnStart, type ChatStateStore, type PendingTurnStart } from "../chat-state"; -import type { DisplayItem } from "../display/types"; -import { implementPlanCandidateFromState } from "../plan-implementation"; +import type { ChatStateStore } from "../chat-state"; import { composerSlotSnapshot, goalSlotSnapshot, messagesSlotSnapshot, toolbarSlotSnapshot } from "../panel/snapshot"; import { renderChatPanelShell } from "../ui/shell"; -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, @@ -37,39 +15,6 @@ export interface ChatShellRenderPort { ): void; } -export function createSubmissionStatePort(stateStore: ChatStateStore): SubmissionStatePort { - return { - snapshot() { - const state = stateStore.getState(); - return { - activeThreadId: state.activeThread.id, - activeTurnId: activeTurnId(state), - busy: chatTurnBusy(state), - listedThreads: state.threadList.listedThreads, - displayItems: state.transcript.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: "transcript/system-message-added", item }); - }, - }; -} - export function createChatShellRenderPort( stateStore: ChatStateStore, options: { diff --git a/src/features/chat/controllers/submission/composer-submission-controller.ts b/src/features/chat/controllers/submission/composer-submission-controller.ts index 8454c5af..45d19204 100644 --- a/src/features/chat/controllers/submission/composer-submission-controller.ts +++ b/src/features/chat/controllers/submission/composer-submission-controller.ts @@ -1,10 +1,11 @@ import type { AppServerClient } from "../../../../app-server/client"; +import { submissionStateSnapshot } from "../../chat-state-selectors"; +import type { ChatStateStore } from "../../chat-state"; import { parseSlashCommand } from "../../composer/suggestions"; import type { SlashCommandExecutionResult } from "../../slash-command-execution"; import type { SlashCommandName } from "../../composer/slash-commands"; import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference"; import type { UserInput } from "../../../../generated/app-server/v2/UserInput"; -import type { SubmissionStatePort } from "../state-ports"; interface ComposerDraftPort { readonly trimmedDraft: string; @@ -30,7 +31,7 @@ interface ComposerStatusPort { } export interface ComposerSubmissionControllerHost { - state: SubmissionStatePort; + stateStore: ChatStateStore; composer: ComposerDraftPort; slashCommands: ComposerSlashCommandPort; turnSubmission: ComposerTurnSubmissionPort; @@ -43,7 +44,7 @@ export class ComposerSubmissionController { async submit(): Promise { const draft = this.host.composer.trimmedDraft; - const state = this.host.state.snapshot(); + const state = submissionStateSnapshot(this.host.stateStore.getState()); if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) { await this.interruptTurn(); return; @@ -75,7 +76,7 @@ export class ComposerSubmissionController { } private async interruptTurn(): Promise { - const state = this.host.state.snapshot(); + const state = submissionStateSnapshot(this.host.stateStore.getState()); const turnId = state.activeTurnId; const client = this.host.connection.currentClient(); if (!client || !state.activeThreadId || !turnId) return; diff --git a/src/features/chat/controllers/submission/plan-implementation-controller.ts b/src/features/chat/controllers/submission/plan-implementation-controller.ts index 8a7f1509..164f18d6 100644 --- a/src/features/chat/controllers/submission/plan-implementation-controller.ts +++ b/src/features/chat/controllers/submission/plan-implementation-controller.ts @@ -1,6 +1,8 @@ import type { AppServerClient } from "../../../../app-server/client"; +import { closePanelsAction, setRequestedCollaborationModeDefaultAction } from "../../chat-state-actions"; +import { activeThreadId, canImplementPlan } from "../../chat-state-selectors"; +import type { ChatStateStore } from "../../chat-state"; import type { DisplayItem } from "../../display/types"; -import type { SubmissionStatePort } from "../state-ports"; const IMPLEMENT_PLAN_PROMPT = "Please implement this plan."; @@ -14,7 +16,7 @@ interface PlanImplementationSubmissionPort { } export interface PlanImplementationControllerHost { - state: SubmissionStatePort; + stateStore: ChatStateStore; connection: PlanImplementationConnectionPort; submission: PlanImplementationSubmissionPort; } @@ -23,15 +25,16 @@ export class PlanImplementationController { constructor(private readonly host: PlanImplementationControllerHost) {} canImplement(item: DisplayItem): boolean { - return this.host.state.canImplementPlan(item); + return canImplementPlan(this.host.stateStore.getState(), item); } async implement(item: DisplayItem): Promise { if (!this.canImplement(item)) return; await this.host.connection.ensureConnected(); - if (!this.host.connection.currentClient() || !this.host.state.snapshot().activeThreadId) return; + if (!this.host.connection.currentClient() || !activeThreadId(this.host.stateStore.getState())) return; - this.host.state.prepareImplementationTurn(); + this.host.stateStore.dispatch(setRequestedCollaborationModeDefaultAction()); + this.host.stateStore.dispatch(closePanelsAction()); await this.host.submission.sendTurnText(IMPLEMENT_PLAN_PROMPT); } } diff --git a/src/features/chat/controllers/submission/slash-command-controller.ts b/src/features/chat/controllers/submission/slash-command-controller.ts index ee6079dd..1c31470a 100644 --- a/src/features/chat/controllers/submission/slash-command-controller.ts +++ b/src/features/chat/controllers/submission/slash-command-controller.ts @@ -16,7 +16,8 @@ import type { ReasoningEffort } from "../../../../generated/app-server/Reasoning import type { ThreadGoal } from "../../../../generated/app-server/v2/ThreadGoal"; import type { ThreadGoalStatus } from "../../../../generated/app-server/v2/ThreadGoalStatus"; import type { UserInput } from "../../../../generated/app-server/v2/UserInput"; -import type { SubmissionStatePort } from "../state-ports"; +import { submissionStateSnapshot } from "../../chat-state-selectors"; +import type { ChatStateStore } from "../../chat-state"; export interface SlashCommandThreadPort { startNewThread: () => Promise; @@ -57,7 +58,7 @@ export interface SlashCommandGoalPort { } export interface SlashCommandControllerHost { - state: SubmissionStatePort; + stateStore: ChatStateStore; currentClient: () => AppServerClient | null; codexInput: (text: string) => UserInput[]; threads: SlashCommandThreadPort; @@ -70,7 +71,7 @@ export class SlashCommandController { constructor(private readonly host: SlashCommandControllerHost) {} async execute(command: SlashCommandName, args: string): Promise { - const state = this.host.state.snapshot(); + const state = submissionStateSnapshot(this.host.stateStore.getState()); const client = this.host.currentClient(); if (!client && command !== "reconnect" && command !== "compact") return; return runSlashCommand(command, args, { diff --git a/src/features/chat/controllers/submission/turn-submission-controller.ts b/src/features/chat/controllers/submission/turn-submission-controller.ts index b3afb645..3abd9efd 100644 --- a/src/features/chat/controllers/submission/turn-submission-controller.ts +++ b/src/features/chat/controllers/submission/turn-submission-controller.ts @@ -1,6 +1,14 @@ import type { AppServerClient } from "../../../../app-server/client"; import type { UserInput } from "../../../../generated/app-server/v2/UserInput"; import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference"; +import { + addLocalUserMessageAction, + optimisticTurnStartedAction, + turnStartAcknowledgedAction, + turnStartFailedAction, +} from "../../chat-state-actions"; +import { submissionStateSnapshot } from "../../chat-state-selectors"; +import type { ChatStateStore } from "../../chat-state"; import { acknowledgeOptimisticTurnStart, cleanupFailedTurnStart, @@ -8,7 +16,6 @@ import { optimisticTurnStart, shouldAcknowledgeTurnStart, } from "./turn-submission"; -import type { SubmissionStatePort } from "../state-ports"; export interface TurnSubmissionConnectionPort { vaultPath: string; @@ -46,7 +53,7 @@ export interface TurnSubmissionStatusPort { } export interface TurnSubmissionControllerHost { - state: SubmissionStatePort; + stateStore: ChatStateStore; connection: TurnSubmissionConnectionPort; restoredThread: TurnSubmissionRestoredThreadPort; thread: TurnSubmissionThreadPort; @@ -64,7 +71,7 @@ export class TurnSubmissionController { const client = this.host.connection.currentClient(); if (!client) return; - const initialState = this.host.state.snapshot(); + const initialState = submissionStateSnapshot(this.host.stateStore.getState()); if (initialState.busy) { await this.steerCurrentTurn(client, text, codexInputOverride, referencedThread); return; @@ -78,7 +85,7 @@ export class TurnSubmissionController { this.host.thread.notifyActiveThreadIdentityChanged(); this.host.thread.resetThreadTurnPresence(false); } - const activeThreadId = this.host.state.snapshot().activeThreadId; + const activeThreadId = submissionStateSnapshot(this.host.stateStore.getState()).activeThreadId; if (!activeThreadId) return; if (!(await this.host.runtime.applyPendingThreadSettings())) return; @@ -90,13 +97,13 @@ export class TurnSubmissionController { codexInput, referencedThread, }); - this.host.state.optimisticTurnStarted(optimistic.item, optimistic.pendingTurnStart); + this.host.stateStore.dispatch(optimisticTurnStartedAction(optimistic.item, optimistic.pendingTurnStart)); this.host.view.forceMessagesToBottom(); this.host.composer.setDraft(""); this.host.view.render(); const response = await client.startTurn(activeThreadId, this.host.connection.vaultPath, codexInput, optimisticUserId); - const acknowledgedState = this.host.state.snapshot(); + const acknowledgedState = submissionStateSnapshot(this.host.stateStore.getState()); const pendingStart = acknowledgedState.pendingTurnStart; if ( shouldAcknowledgeTurnStart({ @@ -112,17 +119,17 @@ export class TurnSubmissionController { turnId: response.turn.id, pendingTurnStart: pendingStart, }); - this.host.state.turnStartAcknowledged(response.turn.id, displayItems); + this.host.stateStore.dispatch(turnStartAcknowledgedAction(response.turn.id, displayItems)); this.host.status.setStatus("Turn running..."); } } catch (error) { - const failedState = this.host.state.snapshot(); + const failedState = submissionStateSnapshot(this.host.stateStore.getState()); const displayItems = cleanupFailedTurnStart({ items: failedState.displayItems, optimisticUserId, pendingTurnStart: failedState.pendingTurnStart, }); - this.host.state.turnStartFailed(displayItems); + this.host.stateStore.dispatch(turnStartFailedAction(displayItems)); this.host.composer.setDraft(text); this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error)); } @@ -135,7 +142,7 @@ export class TurnSubmissionController { codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay, ): Promise { - const state = this.host.state.snapshot(); + const state = submissionStateSnapshot(this.host.stateStore.getState()); const threadId = state.activeThreadId; const expectedTurnId = state.activeTurnId; if (!threadId || !expectedTurnId) { @@ -149,14 +156,16 @@ export class TurnSubmissionController { try { await client.steerTurn(threadId, expectedTurnId, codexInput, localSteerId); - this.host.state.addLocalUserMessage( - localUserMessageItemFromInput({ - id: localSteerId, - text, - turnId: expectedTurnId, - referencedThread, - codexInput, - }), + this.host.stateStore.dispatch( + addLocalUserMessageAction( + localUserMessageItemFromInput({ + id: localSteerId, + text, + turnId: expectedTurnId, + referencedThread, + codexInput, + }), + ), ); this.host.view.forceMessagesToBottom(); this.host.status.setStatus("Steered current turn."); diff --git a/src/features/chat/panel/controllers.ts b/src/features/chat/panel/controllers.ts index e529b405..b78424c9 100644 --- a/src/features/chat/panel/controllers.ts +++ b/src/features/chat/panel/controllers.ts @@ -20,7 +20,7 @@ import { ChatConnectionController } from "../controllers/connection/connection-c 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, createSubmissionStatePort } from "../controllers/state-ports"; +import { createChatShellRenderPort } 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"; @@ -231,7 +231,6 @@ function createControllerContext(ports: ChatPanelContext) { viewId, stateStore, currentClient: client.getClient, - submissionState: createSubmissionStatePort(stateStore), }; } @@ -292,7 +291,6 @@ function createSubmissionControllerGroup( status, lifecycle, currentClient, - submissionState, client, } = context; const { messageScrollIntent } = lifecycle; @@ -326,7 +324,7 @@ function createSubmissionControllerGroup( }); const turnSubmission = new TurnSubmissionController({ - state: submissionState, + stateStore, connection: { vaultPath: plugin.vaultPath, currentClient, @@ -359,7 +357,7 @@ function createSubmissionControllerGroup( }, }); const slashCommands = new SlashCommandController({ - state: submissionState, + stateStore, currentClient, codexInput: (text) => composerController.codexInput(text), threads: { @@ -401,7 +399,7 @@ function createSubmissionControllerGroup( }, }); const planImplementation = new PlanImplementationController({ - state: submissionState, + stateStore, connection: { currentClient, ensureConnected: client.ensureConnected, @@ -440,7 +438,7 @@ function createSubmissionControllerGroup( }, }); const composerSubmission = new ComposerSubmissionController({ - state: submissionState, + stateStore, composer: composerController, slashCommands, turnSubmission, 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 d376276e..eeb1d7ee 100644 --- a/tests/features/chat/controllers/submission/composer-submission-controller.test.ts +++ b/tests/features/chat/controllers/submission/composer-submission-controller.test.ts @@ -3,7 +3,6 @@ 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 { Thread } from "../../../../../src/generated/app-server/v2/Thread"; function thread(id: string): Thread { @@ -39,7 +38,7 @@ function createController(draft: string) { const sendTurnText = vi.fn().mockResolvedValue(undefined); const execute = vi.fn().mockResolvedValue(undefined); const controller = new ComposerSubmissionController({ - state: createSubmissionStatePort(stateStore), + 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 b9cfb293..c3833b52 100644 --- a/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts +++ b/tests/features/chat/controllers/submission/plan-implementation-controller.test.ts @@ -7,7 +7,6 @@ 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 => ({ @@ -40,7 +39,7 @@ function createController({ client = {} as AppServerClient } = {}) { const ensureConnected = vi.fn().mockResolvedValue(undefined); const sendTurnText = vi.fn().mockResolvedValue(undefined); const host: PlanImplementationControllerHost = { - state: createSubmissionStatePort(stateStore), + stateStore, connection: { currentClient: () => client, ensureConnected, 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 83c33430..b19f3649 100644 --- a/tests/features/chat/controllers/submission/slash-command-controller.test.ts +++ b/tests/features/chat/controllers/submission/slash-command-controller.test.ts @@ -10,7 +10,6 @@ import { type SlashCommandStatusPort, type SlashCommandThreadPort, } 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"; @@ -99,7 +98,7 @@ function createHost(overrides: SlashCommandHostOverrides = {}) { ...goalOverrides, }; const host: SlashCommandControllerHost = { - state: createSubmissionStatePort(stateStore), + stateStore, currentClient: () => client, codexInput: vi.fn((text: string) => textInput(text)), threads, 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 fb3f500a..3facb10e 100644 --- a/tests/features/chat/controllers/submission/turn-submission-controller.test.ts +++ b/tests/features/chat/controllers/submission/turn-submission-controller.test.ts @@ -13,7 +13,6 @@ import { type TurnSubmissionThreadPort, type TurnSubmissionViewPort, } 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"; @@ -123,7 +122,7 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) { ...statusOverrides, }; const host: TurnSubmissionControllerHost = { - state: createSubmissionStatePort(stateStore), + stateStore, connection, restoredThread, thread: threadPort,