mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add state ports for chat controllers
This commit is contained in:
parent
5adb721c18
commit
d1f5e84aa0
29 changed files with 406 additions and 211 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { StaleConnectionError } from "../../../../app-server/connection-manager";
|
||||
import type { AppServerClient } from "../../../../app-server/client";
|
||||
import type { InitializeResponse } from "../../../../generated/app-server/InitializeResponse";
|
||||
import type { ChatAction, ChatStateStore } from "../../chat-state";
|
||||
import type { ChatAppServerController } from "../../chat-app-server-controller";
|
||||
import type { ChatConnectionWorkTracker, ActiveChatConnection } from "../../view-lifecycle";
|
||||
import type { ConnectionStatePort } from "../state-ports";
|
||||
|
||||
export interface ChatConnectionAdapter {
|
||||
connect(): Promise<InitializeResponse>;
|
||||
|
|
@ -12,7 +12,7 @@ export interface ChatConnectionAdapter {
|
|||
}
|
||||
|
||||
export interface ChatConnectionControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
state: ConnectionStatePort;
|
||||
connection: ChatConnectionAdapter;
|
||||
connectionWork: ChatConnectionWorkTracker;
|
||||
appServer: ChatAppServerController;
|
||||
|
|
@ -62,7 +62,7 @@ export class ChatConnectionController {
|
|||
this.invalidate();
|
||||
this.host.invalidateResumeWork();
|
||||
this.host.setStatus("Codex app-server stopped.");
|
||||
this.dispatch({ type: "connection/scoped-cleared" });
|
||||
this.host.state.clearConnectionScope();
|
||||
this.host.resetThreadTurnPresence(false);
|
||||
this.host.setClient(null);
|
||||
this.host.refreshLiveState();
|
||||
|
|
@ -110,7 +110,7 @@ export class ChatConnectionController {
|
|||
private async initializeConnection(connection: ActiveChatConnection): Promise<void> {
|
||||
this.host.setStatus("Starting Codex app-server...");
|
||||
try {
|
||||
this.dispatch({ type: "connection/initialized", initializeResponse: await this.host.connection.connect() });
|
||||
this.host.state.connectionInitialized(await this.host.connection.connect());
|
||||
if (this.host.connectionWork.isStale(connection)) return;
|
||||
const client = this.host.connection.currentClient();
|
||||
this.host.setClient(client);
|
||||
|
|
@ -133,10 +133,6 @@ export class ChatConnectionController {
|
|||
this.host.scheduleRender();
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.host.stateStore.dispatch(action);
|
||||
}
|
||||
}
|
||||
|
||||
function connectionErrorMessage(error: unknown, configuredCommand: string): string {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { ChatAction, ChatStateStore } from "../../chat-state";
|
||||
import type { ConnectionStatePort, PanelUiStatePort, ThreadLifecycleStatePort } from "../state-ports";
|
||||
|
||||
export interface ChatReconnectControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
activeThreadId: () => string | null;
|
||||
connectionState: ConnectionStatePort;
|
||||
panelState: PanelUiStatePort;
|
||||
threadState: ThreadLifecycleStatePort;
|
||||
invalidateConnectionWork: () => void;
|
||||
invalidateResumeWork: () => void;
|
||||
clearDeferredDiagnostics: () => void;
|
||||
|
|
@ -19,14 +20,14 @@ export class ChatReconnectController {
|
|||
constructor(private readonly host: ChatReconnectControllerHost) {}
|
||||
|
||||
async reconnectFromToolbar(): Promise<void> {
|
||||
const threadId = this.host.activeThreadId();
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
const threadId = this.host.threadState.activeThreadId();
|
||||
this.host.panelState.closePanels();
|
||||
this.host.invalidateConnectionWork();
|
||||
this.host.invalidateResumeWork();
|
||||
this.host.clearDeferredDiagnostics();
|
||||
this.host.reconnect();
|
||||
this.host.clearClient();
|
||||
this.dispatch({ type: "turn/local-cleared" });
|
||||
this.host.connectionState.clearLocalTurn();
|
||||
this.host.setStatus("Reconnecting...");
|
||||
this.host.render();
|
||||
|
||||
|
|
@ -38,8 +39,4 @@ export class ChatReconnectController {
|
|||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.host.stateStore.dispatch(action);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
213
src/features/chat/controllers/state-ports.ts
Normal file
213
src/features/chat/controllers/state-ports.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import type { InitializeResponse } from "../../../generated/app-server/InitializeResponse";
|
||||
import type { Thread } from "../../../generated/app-server/v2/Thread";
|
||||
import { activeTurnId, chatTurnBusy, pendingTurnStart, type ChatStateStore, type PendingTurnStart } from "../chat-state";
|
||||
import type { PendingApproval } from "../approvals/model";
|
||||
import type { PendingUserInput } from "../user-input/model";
|
||||
import type { DisplayItem } from "../display/types";
|
||||
import { implementPlanCandidateFromState } from "../plan-implementation";
|
||||
import { resumedThreadAction, type ThreadActivationResponse } from "../thread-resume";
|
||||
import { composerSlotSnapshot, messagesSlotSnapshot, toolbarSlotSnapshot } from "../view-snapshot";
|
||||
import { renderChatPanelShell } from "../ui/shell";
|
||||
|
||||
export interface ConnectionStatePort {
|
||||
connectionInitialized(initializeResponse: InitializeResponse): void;
|
||||
clearConnectionScope(): void;
|
||||
clearLocalTurn(): void;
|
||||
}
|
||||
|
||||
export interface PanelUiStatePort {
|
||||
closePanels(): void;
|
||||
pinMessagesToBottom(): void;
|
||||
}
|
||||
|
||||
export interface PendingRequestSnapshot {
|
||||
approvals: readonly PendingApproval[];
|
||||
pendingUserInputs: readonly PendingUserInput[];
|
||||
userInputDrafts: ReadonlyMap<string, string>;
|
||||
openDetails: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface PendingRequestStatePort {
|
||||
snapshot(): PendingRequestSnapshot;
|
||||
setDetailOpen(key: string, open: boolean): void;
|
||||
setUserInputDraft(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface ThreadLifecycleStatePort {
|
||||
activeThreadId(): string | null;
|
||||
canSwitchToThread(threadId: string): boolean;
|
||||
listedThreads: readonly Thread[];
|
||||
clearActiveThread(): void;
|
||||
applyThreadList(threads: readonly Thread[]): void;
|
||||
restorePlaceholder(threadId: string, item: DisplayItem): void;
|
||||
displayItemsEmpty(): boolean;
|
||||
applyResumedThread(response: ThreadActivationResponse, displayItems: readonly DisplayItem[]): void;
|
||||
}
|
||||
|
||||
export interface SubmissionStateSnapshot {
|
||||
activeThreadId: string | null;
|
||||
activeTurnId: string | null;
|
||||
busy: boolean;
|
||||
listedThreads: readonly Thread[];
|
||||
displayItems: readonly DisplayItem[];
|
||||
pendingTurnStart: PendingTurnStart | null;
|
||||
}
|
||||
|
||||
export interface SubmissionStatePort {
|
||||
snapshot(): SubmissionStateSnapshot;
|
||||
canImplementPlan(item: DisplayItem): boolean;
|
||||
prepareImplementationTurn(): void;
|
||||
optimisticTurnStarted(item: DisplayItem, pendingStart: PendingTurnStart): void;
|
||||
turnStartAcknowledged(turnId: string, displayItems: readonly DisplayItem[]): void;
|
||||
turnStartFailed(displayItems: readonly DisplayItem[]): void;
|
||||
addLocalUserMessage(item: DisplayItem): void;
|
||||
}
|
||||
|
||||
export interface ChatShellRenderPort {
|
||||
render(
|
||||
root: HTMLElement,
|
||||
renderVersion: number,
|
||||
slots: {
|
||||
renderToolbar: (toolbar: HTMLElement) => void;
|
||||
renderMessages: (parent: HTMLElement) => void;
|
||||
renderComposer: (parent: HTMLElement) => void;
|
||||
},
|
||||
): void;
|
||||
}
|
||||
|
||||
export function createConnectionStatePort(stateStore: ChatStateStore): ConnectionStatePort {
|
||||
return {
|
||||
connectionInitialized(initializeResponse) {
|
||||
stateStore.dispatch({ type: "connection/initialized", initializeResponse });
|
||||
},
|
||||
clearConnectionScope() {
|
||||
stateStore.dispatch({ type: "connection/scoped-cleared" });
|
||||
},
|
||||
clearLocalTurn() {
|
||||
stateStore.dispatch({ type: "turn/local-cleared" });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPanelUiStatePort(stateStore: ChatStateStore): PanelUiStatePort {
|
||||
return {
|
||||
closePanels() {
|
||||
stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
},
|
||||
pinMessagesToBottom() {
|
||||
stateStore.dispatch({ type: "ui/messages-pinned-set", pinned: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPendingRequestStatePort(stateStore: ChatStateStore): PendingRequestStatePort {
|
||||
return {
|
||||
snapshot() {
|
||||
const state = stateStore.getState();
|
||||
return {
|
||||
approvals: state.approvals,
|
||||
pendingUserInputs: state.pendingUserInputs,
|
||||
userInputDrafts: state.userInputDrafts,
|
||||
openDetails: state.openDetails,
|
||||
};
|
||||
},
|
||||
setDetailOpen(key, open) {
|
||||
stateStore.dispatch({ type: "ui/detail-open-set", key, open });
|
||||
},
|
||||
setUserInputDraft(key, value) {
|
||||
stateStore.dispatch({ type: "request/user-input-draft-set", key, value });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreadLifecycleStatePort(stateStore: ChatStateStore): ThreadLifecycleStatePort {
|
||||
return {
|
||||
activeThreadId: () => stateStore.getState().activeThreadId,
|
||||
canSwitchToThread(threadId) {
|
||||
const state = stateStore.getState();
|
||||
return !chatTurnBusy(state) || threadId === state.activeThreadId;
|
||||
},
|
||||
get listedThreads() {
|
||||
return stateStore.getState().listedThreads;
|
||||
},
|
||||
clearActiveThread() {
|
||||
stateStore.dispatch({ type: "thread/active-cleared" });
|
||||
},
|
||||
applyThreadList(threads) {
|
||||
stateStore.dispatch({ type: "thread/list-applied", threads });
|
||||
},
|
||||
restorePlaceholder(threadId, item) {
|
||||
stateStore.dispatch({ type: "thread/restored-placeholder", threadId, item });
|
||||
},
|
||||
displayItemsEmpty: () => stateStore.getState().displayItems.length === 0,
|
||||
applyResumedThread(response, displayItems) {
|
||||
stateStore.dispatch(
|
||||
resumedThreadAction({
|
||||
response,
|
||||
listedThreads: stateStore.getState().listedThreads,
|
||||
displayItems,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubmissionStatePort(stateStore: ChatStateStore): SubmissionStatePort {
|
||||
return {
|
||||
snapshot() {
|
||||
const state = stateStore.getState();
|
||||
return {
|
||||
activeThreadId: state.activeThreadId,
|
||||
activeTurnId: activeTurnId(state),
|
||||
busy: chatTurnBusy(state),
|
||||
listedThreads: state.listedThreads,
|
||||
displayItems: state.displayItems,
|
||||
pendingTurnStart: pendingTurnStart(state),
|
||||
};
|
||||
},
|
||||
canImplementPlan: (item) => item.id === implementPlanCandidateFromState(stateStore.getState())?.id,
|
||||
prepareImplementationTurn() {
|
||||
stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
|
||||
stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
},
|
||||
optimisticTurnStarted(item, pendingStart) {
|
||||
stateStore.dispatch({ type: "turn/optimistic-started", item, pendingTurnStart: pendingStart });
|
||||
},
|
||||
turnStartAcknowledged(turnId, displayItems) {
|
||||
stateStore.dispatch({ type: "turn/start-acknowledged", turnId, displayItems });
|
||||
},
|
||||
turnStartFailed(displayItems) {
|
||||
stateStore.dispatch({ type: "turn/start-failed", displayItems });
|
||||
},
|
||||
addLocalUserMessage(item) {
|
||||
stateStore.dispatch({ type: "system/message-added", item });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createChatShellRenderPort(
|
||||
stateStore: ChatStateStore,
|
||||
options: {
|
||||
connected: () => boolean;
|
||||
pendingRequestsSignature: () => string;
|
||||
activeComposerThreadName: () => string | null;
|
||||
},
|
||||
): ChatShellRenderPort {
|
||||
return {
|
||||
render(root, renderVersion, slots) {
|
||||
renderChatPanelShell(root, {
|
||||
stateStore,
|
||||
renderVersion,
|
||||
toolbar: { render: slots.renderToolbar, snapshot: (state) => toolbarSlotSnapshot(state, options.connected()) },
|
||||
messages: {
|
||||
render: slots.renderMessages,
|
||||
snapshot: (state) => messagesSlotSnapshot(state, options.pendingRequestsSignature()),
|
||||
},
|
||||
composer: {
|
||||
render: slots.renderComposer,
|
||||
snapshot: (state) => composerSlotSnapshot(state, options.activeComposerThreadName()),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import type { AppServerClient } from "../../../../app-server/client";
|
||||
import { parseSlashCommand } from "../../composer/suggestions";
|
||||
import { activeTurnId, chatTurnBusy, type ChatStateStore } from "../../chat-state";
|
||||
import type { ChatComposerController } from "../../chat-composer-controller";
|
||||
import type { SlashCommandController } from "./slash-command-controller";
|
||||
import type { TurnSubmissionController } from "./turn-submission-controller";
|
||||
import type { SubmissionStatePort } from "../state-ports";
|
||||
|
||||
export interface ComposerSubmissionControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
state: SubmissionStatePort;
|
||||
composer: ChatComposerController;
|
||||
slashCommands: SlashCommandController;
|
||||
turnSubmission: TurnSubmissionController;
|
||||
|
|
@ -21,8 +21,8 @@ export class ComposerSubmissionController {
|
|||
|
||||
async submit(): Promise<void> {
|
||||
const draft = this.host.composer.trimmedDraft;
|
||||
const state = this.host.stateStore.getState();
|
||||
if (chatTurnBusy(state) && state.activeThreadId && activeTurnId(state) && draft.length === 0) {
|
||||
const state = this.host.state.snapshot();
|
||||
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
|
||||
await this.interruptTurn();
|
||||
return;
|
||||
}
|
||||
|
|
@ -50,8 +50,8 @@ export class ComposerSubmissionController {
|
|||
}
|
||||
|
||||
private async interruptTurn(): Promise<void> {
|
||||
const state = this.host.stateStore.getState();
|
||||
const turnId = activeTurnId(state);
|
||||
const state = this.host.state.snapshot();
|
||||
const turnId = state.activeTurnId;
|
||||
const client = this.host.currentClient();
|
||||
if (!client || !state.activeThreadId || !turnId) return;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import type { AppServerClient } from "../../../../app-server/client";
|
||||
import type { ChatState, ChatStateStore } from "../../chat-state";
|
||||
import type { DisplayItem } from "../../display/types";
|
||||
import { implementPlanCandidateFromState } from "../../plan-implementation";
|
||||
import type { SubmissionStatePort } from "../state-ports";
|
||||
|
||||
const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
|
||||
|
||||
export interface PlanImplementationControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
state: SubmissionStatePort;
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureConnected: () => Promise<void>;
|
||||
sendTurnText: (text: string) => Promise<void>;
|
||||
|
|
@ -16,20 +15,15 @@ export class PlanImplementationController {
|
|||
constructor(private readonly host: PlanImplementationControllerHost) {}
|
||||
|
||||
canImplement(item: DisplayItem): boolean {
|
||||
return item.id === implementPlanCandidateFromState(this.state)?.id;
|
||||
return this.host.state.canImplementPlan(item);
|
||||
}
|
||||
|
||||
async implement(item: DisplayItem): Promise<void> {
|
||||
if (!this.canImplement(item)) return;
|
||||
await this.host.ensureConnected();
|
||||
if (!this.host.currentClient() || !this.state.activeThreadId) return;
|
||||
if (!this.host.currentClient() || !this.host.state.snapshot().activeThreadId) return;
|
||||
|
||||
this.host.stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" });
|
||||
this.host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
|
||||
this.host.state.prepareImplementationTurn();
|
||||
await this.host.sendTurnText(IMPLEMENT_PLAN_PROMPT);
|
||||
}
|
||||
|
||||
private get state(): ChatState {
|
||||
return this.host.stateStore.getState();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@ import {
|
|||
REFERENCED_THREAD_TURN_LIMIT,
|
||||
} from "../../../../domain/threads/reference";
|
||||
import type { Thread } from "../../../../generated/app-server/v2/Thread";
|
||||
import { chatTurnBusy, type ChatStateStore } from "../../chat-state";
|
||||
import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult, type ThreadReferenceInput } from "../../slash-commands";
|
||||
import type { SlashCommandName } from "../../composer/slash-commands";
|
||||
import type { DisplayDetailSection } from "../../display/types";
|
||||
import type { ReasoningEffort } from "../../../../generated/app-server/ReasoningEffort";
|
||||
import type { UserInput } from "../../../../generated/app-server/v2/UserInput";
|
||||
import type { SubmissionStatePort } from "../state-ports";
|
||||
|
||||
export interface SlashCommandControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
state: SubmissionStatePort;
|
||||
currentClient: () => AppServerClient | null;
|
||||
codexInput: (text: string) => UserInput[];
|
||||
startNewThread: () => Promise<void>;
|
||||
|
|
@ -42,7 +42,7 @@ export class SlashCommandController {
|
|||
async execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
const state = this.host.stateStore.getState();
|
||||
const state = this.host.state.snapshot();
|
||||
return runSlashCommand(command, args, {
|
||||
activeThreadId: state.activeThreadId,
|
||||
listedThreads: state.listedThreads,
|
||||
|
|
@ -55,7 +55,7 @@ export class SlashCommandController {
|
|||
await client.compactThread(threadId);
|
||||
},
|
||||
archiveThread: (threadId) => this.host.archiveThread(threadId),
|
||||
busy: chatTurnBusy(state),
|
||||
busy: state.busy,
|
||||
toggleFastMode: () => this.host.toggleFastMode(),
|
||||
toggleCollaborationMode: () => this.host.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => this.host.toggleAutoReview(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { AppServerClient } from "../../../../app-server/client";
|
||||
import type { UserInput } from "../../../../generated/app-server/v2/UserInput";
|
||||
import { activeTurnId, chatTurnBusy, pendingTurnStart, type ChatStateStore } from "../../chat-state";
|
||||
import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference";
|
||||
import {
|
||||
acknowledgeOptimisticTurnStart,
|
||||
|
|
@ -9,9 +8,10 @@ import {
|
|||
optimisticTurnStart,
|
||||
shouldAcknowledgeTurnStart,
|
||||
} from "./turn-submission";
|
||||
import type { SubmissionStatePort } from "../state-ports";
|
||||
|
||||
export interface TurnSubmissionControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
state: SubmissionStatePort;
|
||||
vaultPath: string;
|
||||
currentClient: () => AppServerClient | null;
|
||||
ensureRestoredThreadLoaded: () => Promise<boolean>;
|
||||
|
|
@ -36,7 +36,7 @@ export class TurnSubmissionController {
|
|||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
|
||||
if (chatTurnBusy(this.state)) {
|
||||
if (this.state.busy) {
|
||||
await this.steerCurrentTurn(client, text, codexInputOverride, referencedThread);
|
||||
return;
|
||||
}
|
||||
|
|
@ -61,21 +61,17 @@ export class TurnSubmissionController {
|
|||
codexInput,
|
||||
referencedThread,
|
||||
});
|
||||
this.host.stateStore.dispatch({
|
||||
type: "turn/optimistic-started",
|
||||
item: optimistic.item,
|
||||
pendingTurnStart: optimistic.pendingTurnStart,
|
||||
});
|
||||
this.host.state.optimisticTurnStarted(optimistic.item, optimistic.pendingTurnStart);
|
||||
this.host.forceMessagesToBottom();
|
||||
this.host.setDraft("");
|
||||
this.host.render();
|
||||
|
||||
const response = await client.startTurn(activeThreadId, this.host.vaultPath, codexInput);
|
||||
const pendingStart = pendingTurnStart(this.state);
|
||||
const pendingStart = this.state.pendingTurnStart;
|
||||
if (
|
||||
shouldAcknowledgeTurnStart({
|
||||
pendingTurnStart: pendingStart,
|
||||
activeTurnId: activeTurnIdForState(this.host.stateStore),
|
||||
activeTurnId: this.state.activeTurnId,
|
||||
optimisticUserId,
|
||||
responseTurnId: response.turn.id,
|
||||
})
|
||||
|
|
@ -86,16 +82,16 @@ export class TurnSubmissionController {
|
|||
turnId: response.turn.id,
|
||||
pendingTurnStart: pendingStart,
|
||||
});
|
||||
this.host.stateStore.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, displayItems });
|
||||
this.host.state.turnStartAcknowledged(response.turn.id, displayItems);
|
||||
this.host.setStatus("Turn running...");
|
||||
}
|
||||
} catch (error) {
|
||||
const displayItems = cleanupFailedTurnStart({
|
||||
items: this.state.displayItems,
|
||||
optimisticUserId,
|
||||
pendingTurnStart: pendingTurnStart(this.state),
|
||||
pendingTurnStart: this.state.pendingTurnStart,
|
||||
});
|
||||
this.host.stateStore.dispatch({ type: "turn/start-failed", displayItems });
|
||||
this.host.state.turnStartFailed(displayItems);
|
||||
this.host.setDraft(text);
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
|
@ -109,7 +105,7 @@ export class TurnSubmissionController {
|
|||
referencedThread?: ReferencedThreadDisplay,
|
||||
): Promise<void> {
|
||||
const threadId = this.state.activeThreadId;
|
||||
const expectedTurnId = activeTurnIdForState(this.host.stateStore);
|
||||
const expectedTurnId = this.state.activeTurnId;
|
||||
if (!threadId || !expectedTurnId) {
|
||||
this.host.addSystemMessage("Current turn is not steerable yet.");
|
||||
return;
|
||||
|
|
@ -120,16 +116,15 @@ export class TurnSubmissionController {
|
|||
|
||||
try {
|
||||
await client.steerTurn(threadId, expectedTurnId, codexInput);
|
||||
this.host.stateStore.dispatch({
|
||||
type: "system/message-added",
|
||||
item: localUserMessageItemFromInput({
|
||||
this.host.state.addLocalUserMessage(
|
||||
localUserMessageItemFromInput({
|
||||
id: `local-steer-${String(Date.now())}`,
|
||||
text,
|
||||
turnId: expectedTurnId,
|
||||
referencedThread,
|
||||
codexInput,
|
||||
}),
|
||||
});
|
||||
);
|
||||
this.host.forceMessagesToBottom();
|
||||
this.host.setStatus("Steered current turn.");
|
||||
} catch (error) {
|
||||
|
|
@ -141,10 +136,6 @@ export class TurnSubmissionController {
|
|||
}
|
||||
|
||||
private get state() {
|
||||
return this.host.stateStore.getState();
|
||||
return this.host.state.snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
function activeTurnIdForState(stateStore: ChatStateStore): string | null {
|
||||
return activeTurnId(stateStore.getState());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ChatAction } from "../../chat-state";
|
||||
import type { DisplayItem } from "../../display/types";
|
||||
import type { ThreadLifecycleStatePort } from "../state-ports";
|
||||
import {
|
||||
transitionRestoredThreadLifecycle,
|
||||
type RestoredThreadLifecycleState,
|
||||
|
|
@ -13,7 +13,7 @@ export interface RestoredThreadControllerHost {
|
|||
opened: () => boolean;
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
invalidateResumeWork: () => void;
|
||||
dispatch: (action: ChatAction) => void;
|
||||
state: ThreadLifecycleStatePort;
|
||||
systemItem: (text: string) => DisplayItem;
|
||||
setStatus: (status: string) => void;
|
||||
refreshTabHeader: () => void;
|
||||
|
|
@ -48,11 +48,7 @@ export class RestoredThreadController {
|
|||
type: "placeholder-restored",
|
||||
restoredThread,
|
||||
});
|
||||
this.host.dispatch({
|
||||
type: "thread/restored-placeholder",
|
||||
threadId: restoredThread.threadId,
|
||||
item: this.host.systemItem("Thread restored. Send a message to resume it."),
|
||||
});
|
||||
this.host.state.restorePlaceholder(restoredThread.threadId, this.host.systemItem("Thread restored. Send a message to resume it."));
|
||||
this.host.setStatus("Thread ready to resume.");
|
||||
this.host.refreshTabHeader();
|
||||
this.scheduleHydration();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import type { AppServerClient } from "../../../../app-server/client";
|
||||
import type { ChatStateStore } from "../../chat-state";
|
||||
import { chatTurnBusy } from "../../chat-state";
|
||||
import type { DisplayItem } from "../../display/types";
|
||||
import type { RestoredThreadController } from "./restored-thread-controller";
|
||||
import { resumedThreadAction, type ThreadActivationResponse } from "../../thread-resume";
|
||||
import type { ThreadActivationResponse } from "../../thread-resume";
|
||||
import type { ThreadHistoryLoader } from "../../thread-history";
|
||||
import type { ChatResumeWorkTracker, ActiveChatResume } from "../../view-lifecycle";
|
||||
import type { ThreadLifecycleStatePort } from "../state-ports";
|
||||
|
||||
export interface ThreadResumeControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
state: ThreadLifecycleStatePort;
|
||||
vaultPath: string;
|
||||
resumeWork: ChatResumeWorkTracker;
|
||||
history: ThreadHistoryLoader;
|
||||
|
|
@ -30,8 +29,7 @@ export class ThreadResumeController {
|
|||
constructor(private readonly host: ThreadResumeControllerHost) {}
|
||||
|
||||
async resumeThread(threadId: string): Promise<void> {
|
||||
const state = this.host.stateStore.getState();
|
||||
if (chatTurnBusy(state) && threadId !== state.activeThreadId) {
|
||||
if (!this.host.state.canSwitchToThread(threadId)) {
|
||||
this.host.addSystemMessage("Finish or interrupt the current turn before switching threads.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -46,7 +44,7 @@ export class ThreadResumeController {
|
|||
this.applyResumedThread(response);
|
||||
await this.host.history.loadLatest(response.thread.id);
|
||||
if (this.isStale(resume)) return;
|
||||
if (this.host.stateStore.getState().displayItems.length === 0) {
|
||||
if (this.host.state.displayItemsEmpty()) {
|
||||
this.host.addSystemMessage(`Resumed thread ${response.thread.id}`);
|
||||
this.host.forceMessagesToBottom();
|
||||
this.host.render();
|
||||
|
|
@ -59,13 +57,7 @@ export class ThreadResumeController {
|
|||
}
|
||||
|
||||
private applyResumedThread(response: ThreadActivationResponse): void {
|
||||
this.host.stateStore.dispatch(
|
||||
resumedThreadAction({
|
||||
response,
|
||||
listedThreads: this.host.stateStore.getState().listedThreads,
|
||||
displayItems: [this.host.systemItem("Loading thread...")],
|
||||
}),
|
||||
);
|
||||
this.host.state.applyResumedThread(response, [this.host.systemItem("Loading thread...")]);
|
||||
this.host.restoredThread.clear();
|
||||
this.host.clearDeferredRestoredThreadHydration();
|
||||
this.host.resetThreadTurnPresence(false);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../../chat-state";
|
||||
import type { PanelUiStatePort, ThreadLifecycleStatePort } from "../state-ports";
|
||||
|
||||
export interface ThreadSelectionControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
panelState: PanelUiStatePort;
|
||||
threadState: ThreadLifecycleStatePort;
|
||||
closeForThreadSelection: () => void;
|
||||
focusThreadInOpenView: (threadId: string) => Promise<boolean>;
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
|
|
@ -12,7 +13,7 @@ export class ThreadSelectionController {
|
|||
constructor(private readonly host: ThreadSelectionControllerHost) {}
|
||||
|
||||
async selectThread(threadId: string): Promise<void> {
|
||||
if (chatTurnBusy(this.state) && threadId !== this.state.activeThreadId) {
|
||||
if (!this.host.threadState.canSwitchToThread(threadId)) {
|
||||
this.host.addSystemMessage("Finish or interrupt the current turn before switching threads.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -23,17 +24,9 @@ export class ThreadSelectionController {
|
|||
}
|
||||
|
||||
async selectThreadFromToolbar(threadId: string): Promise<void> {
|
||||
if (chatTurnBusy(this.state) && threadId !== this.state.activeThreadId) return;
|
||||
if (!this.host.threadState.canSwitchToThread(threadId)) return;
|
||||
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
this.host.panelState.closePanels();
|
||||
await this.selectThread(threadId);
|
||||
}
|
||||
|
||||
private get state(): ChatState {
|
||||
return this.host.stateStore.getState();
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.host.stateStore.dispatch(action);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -5,13 +5,30 @@ import {
|
|||
ChatReconnectController,
|
||||
type ChatReconnectControllerHost,
|
||||
} from "../../../../../src/features/chat/controllers/connection/reconnect-controller";
|
||||
import {
|
||||
createConnectionStatePort,
|
||||
createPanelUiStatePort,
|
||||
createThreadLifecycleStatePort,
|
||||
} from "../../../../../src/features/chat/controllers/state-ports";
|
||||
|
||||
function createHost(overrides: Partial<ChatReconnectControllerHost> = {}) {
|
||||
const stateStore = createChatStateStore(createChatState());
|
||||
stateStore.dispatch({ type: "ui/panel-set", panel: "history" });
|
||||
stateStore.dispatch({
|
||||
type: "thread/resumed",
|
||||
thread: { id: "thread" } as never,
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalPolicy: null,
|
||||
approvalsReviewer: null,
|
||||
activePermissionProfile: null,
|
||||
});
|
||||
const host: ChatReconnectControllerHost = {
|
||||
stateStore,
|
||||
activeThreadId: () => "thread",
|
||||
connectionState: createConnectionStatePort(stateStore),
|
||||
panelState: createPanelUiStatePort(stateStore),
|
||||
threadState: createThreadLifecycleStatePort(stateStore),
|
||||
invalidateConnectionWork: vi.fn(),
|
||||
invalidateResumeWork: vi.fn(),
|
||||
clearDeferredDiagnostics: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
SlashCommandController,
|
||||
type SlashCommandControllerHost,
|
||||
} from "../../../../../src/features/chat/controllers/submission/slash-command-controller";
|
||||
import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports";
|
||||
import type { Thread } from "../../../../../src/generated/app-server/v2/Thread";
|
||||
import type { UserInput } from "../../../../../src/generated/app-server/v2/UserInput";
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ function createHost(overrides: Partial<SlashCommandControllerHost> = {}) {
|
|||
const threadTurnsList = vi.fn().mockResolvedValue({ data: [] });
|
||||
const client = { compactThread, threadTurnsList } as unknown as AppServerClient;
|
||||
const host: SlashCommandControllerHost = {
|
||||
stateStore,
|
||||
state: createSubmissionStatePort(stateStore),
|
||||
currentClient: () => client,
|
||||
codexInput: vi.fn((text: string) => textInput(text)),
|
||||
startNewThread: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
TurnSubmissionController,
|
||||
type TurnSubmissionControllerHost,
|
||||
} from "../../../../../src/features/chat/controllers/submission/turn-submission-controller";
|
||||
import { createSubmissionStatePort } from "../../../../../src/features/chat/controllers/state-ports";
|
||||
import type { Thread } from "../../../../../src/generated/app-server/v2/Thread";
|
||||
import type { UserInput } from "../../../../../src/generated/app-server/v2/UserInput";
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ function createHost(overrides: Partial<TurnSubmissionControllerHost> = {}) {
|
|||
steerTurn,
|
||||
} as unknown as AppServerClient;
|
||||
const host: TurnSubmissionControllerHost = {
|
||||
stateStore,
|
||||
state: createSubmissionStatePort(stateStore),
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
ensureRestoredThreadLoaded: vi.fn().mockResolvedValue(true),
|
||||
|
|
|
|||
|
|
@ -2,21 +2,21 @@
|
|||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ThreadLifecycleStatePort } from "../../../../../src/features/chat/controllers/state-ports";
|
||||
import { RestoredThreadController } from "../../../../../src/features/chat/controllers/thread/restored-thread-controller";
|
||||
import { ChatViewDeferredTasks } from "../../../../../src/features/chat/view-lifecycle";
|
||||
import type { ChatAction } from "../../../../../src/features/chat/chat-state";
|
||||
|
||||
describe("RestoredThreadController", () => {
|
||||
it("restores a placeholder and schedules deferred hydration", () => {
|
||||
vi.useFakeTimers();
|
||||
const actions: ChatAction[] = [];
|
||||
const restorePlaceholder = vi.fn();
|
||||
const resumeThread = vi.fn().mockResolvedValue(undefined);
|
||||
const controller = new RestoredThreadController({
|
||||
deferredTasks: new ChatViewDeferredTasks(() => window),
|
||||
opened: () => true,
|
||||
resumeThread,
|
||||
invalidateResumeWork: vi.fn(),
|
||||
dispatch: (action) => actions.push(action),
|
||||
state: restoredThreadState({ restorePlaceholder }),
|
||||
systemItem: (text) => ({ id: "system", kind: "system", role: "system", text }),
|
||||
setStatus: vi.fn(),
|
||||
refreshTabHeader: vi.fn(),
|
||||
|
|
@ -25,7 +25,7 @@ describe("RestoredThreadController", () => {
|
|||
controller.restore({ threadId: "thread", title: "Title", explicitName: null });
|
||||
|
||||
expect(controller.placeholder()).toMatchObject({ threadId: "thread", title: "Title" });
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(restorePlaceholder).toHaveBeenCalledOnce();
|
||||
vi.advanceTimersByTime(1_500);
|
||||
expect(resumeThread).toHaveBeenCalledWith("thread");
|
||||
vi.useRealTimers();
|
||||
|
|
@ -63,7 +63,7 @@ function restoredThreadControllerFixture(overrides: Partial<ConstructorParameter
|
|||
opened: () => false,
|
||||
resumeThread: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateResumeWork: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
state: restoredThreadState(),
|
||||
systemItem: (text) => ({ id: "system", kind: "system", role: "system", text }),
|
||||
setStatus: vi.fn(),
|
||||
refreshTabHeader: vi.fn(),
|
||||
|
|
@ -71,6 +71,20 @@ function restoredThreadControllerFixture(overrides: Partial<ConstructorParameter
|
|||
});
|
||||
}
|
||||
|
||||
function restoredThreadState(overrides: Partial<ThreadLifecycleStatePort> = {}): ThreadLifecycleStatePort {
|
||||
return {
|
||||
activeThreadId: () => null,
|
||||
canSwitchToThread: () => true,
|
||||
listedThreads: [],
|
||||
clearActiveThread: vi.fn(),
|
||||
applyThreadList: vi.fn(),
|
||||
restorePlaceholder: vi.fn(),
|
||||
displayItemsEmpty: () => true,
|
||||
applyResumedThread: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createChatState, createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/chat-state";
|
||||
import { createPanelUiStatePort, createThreadLifecycleStatePort } from "../../../../../src/features/chat/controllers/state-ports";
|
||||
import {
|
||||
ThreadSelectionController,
|
||||
type ThreadSelectionControllerHost,
|
||||
|
|
@ -23,7 +24,8 @@ function resumeThreadState(stateStore: ChatStateStore, threadId: string): void {
|
|||
function createController(overrides: Partial<ThreadSelectionControllerHost> = {}) {
|
||||
const stateStore = createChatStateStore(createChatState());
|
||||
const host: ThreadSelectionControllerHost = {
|
||||
stateStore,
|
||||
panelState: createPanelUiStatePort(stateStore),
|
||||
threadState: createThreadLifecycleStatePort(stateStore),
|
||||
closeForThreadSelection: vi.fn(),
|
||||
focusThreadInOpenView: vi.fn().mockResolvedValue(false),
|
||||
resumeThread: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,15 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/chat-state";
|
||||
import type { ChatShellRenderPort } from "../../../../../src/features/chat/controllers/state-ports";
|
||||
import { ChatViewRenderController } from "../../../../../src/features/chat/controllers/view/view-render-controller";
|
||||
import { renderChatPanelShell } from "../../../../../src/features/chat/ui/shell";
|
||||
|
||||
vi.mock("../../../../../src/features/chat/ui/shell", () => ({
|
||||
renderChatPanelShell: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ChatViewRenderController", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(renderChatPanelShell).mockClear();
|
||||
});
|
||||
|
||||
it("renders the shell with stable slot delegates", () => {
|
||||
const root = document.createElement("div");
|
||||
const root = {} as HTMLElement;
|
||||
const render = vi.fn<ChatShellRenderPort["render"]>();
|
||||
const controller = new ChatViewRenderController({
|
||||
stateStore: createChatStateStore(createChatState()),
|
||||
shell: { render },
|
||||
panelRoot: () => root,
|
||||
connected: () => true,
|
||||
pendingRequestsSignature: () => "",
|
||||
activeComposerThreadName: () => null,
|
||||
renderToolbar: vi.fn(),
|
||||
renderMessages: vi.fn(),
|
||||
renderComposer: vi.fn(),
|
||||
|
|
@ -31,25 +18,26 @@ describe("ChatViewRenderController", () => {
|
|||
|
||||
controller.render();
|
||||
|
||||
expect(renderChatPanelShell).toHaveBeenCalledWith(
|
||||
expect(render).toHaveBeenCalledWith(
|
||||
root,
|
||||
0,
|
||||
expect.objectContaining({
|
||||
renderVersion: 0,
|
||||
toolbar: expect.objectContaining({ render: expect.any(Function), snapshot: expect.any(Function) }),
|
||||
messages: expect.objectContaining({ render: expect.any(Function), snapshot: expect.any(Function) }),
|
||||
composer: expect.objectContaining({ render: expect.any(Function), snapshot: expect.any(Function) }),
|
||||
renderToolbar: expect.any(Function),
|
||||
renderMessages: expect.any(Function),
|
||||
renderComposer: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("increments the render version when forcing slot rerender", () => {
|
||||
const root = document.createElement("div");
|
||||
const root = {} as HTMLElement;
|
||||
const renderVersions: number[] = [];
|
||||
const render: ChatShellRenderPort["render"] = vi.fn((_root: HTMLElement, renderVersion: number) => {
|
||||
renderVersions.push(renderVersion);
|
||||
});
|
||||
const controller = new ChatViewRenderController({
|
||||
stateStore: createChatStateStore(createChatState()),
|
||||
shell: { render },
|
||||
panelRoot: () => root,
|
||||
connected: () => true,
|
||||
pendingRequestsSignature: () => "",
|
||||
activeComposerThreadName: () => null,
|
||||
renderToolbar: vi.fn(),
|
||||
renderMessages: vi.fn(),
|
||||
renderComposer: vi.fn(),
|
||||
|
|
@ -59,6 +47,6 @@ describe("ChatViewRenderController", () => {
|
|||
controller.render();
|
||||
controller.renderShellSlots();
|
||||
|
||||
expect(vi.mocked(renderChatPanelShell).mock.calls.map(([, model]) => model.renderVersion)).toEqual([0, 1]);
|
||||
expect(renderVersions).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue