Replace submission state port

This commit is contained in:
murashit 2026-06-08 08:32:19 +09:00
parent 1255762185
commit 3df685e222
12 changed files with 103 additions and 104 deletions

View file

@ -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 };
}

View file

@ -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<string>;
}
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;
}

View file

@ -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: {

View file

@ -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<void> {
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<void> {
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;

View file

@ -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<void> {
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);
}
}

View file

@ -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<void>;
@ -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<SlashCommandExecutionResult | undefined> {
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, {

View file

@ -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<void> {
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.");

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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,