Split chat composition boundaries

This commit is contained in:
murashit 2026-06-13 17:39:32 +09:00
parent 8d2785781b
commit cb866541e5
24 changed files with 1183 additions and 744 deletions

View file

@ -28,18 +28,14 @@ export class StaleConnectionError extends Error {
export class ConnectionManager {
private state: ConnectionLifecycleState = { kind: "idle", generation: 0 };
private handlers: ConnectionManagerHandlers | null = null;
constructor(
private readonly codexPath: () => string,
private readonly cwd: string,
private readonly handlers: ConnectionManagerHandlers,
private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers),
) {}
setHandlers(handlers: ConnectionManagerHandlers): void {
this.handlers = handlers;
}
currentClient(): AppServerClient | null {
return this.state.kind === "connected" && this.state.client.isConnected() ? this.state.client : null;
}
@ -59,20 +55,20 @@ export class ConnectionManager {
const client = this.clientFactory(this.codexPath(), this.cwd, {
onNotification: (notification) => {
if (this.isStale(generation)) return;
this.currentHandlers().onNotification(notification);
this.handlers.onNotification(notification);
},
onServerRequest: (request) => {
if (this.isStale(generation)) return;
this.currentHandlers().onServerRequest(request);
this.handlers.onServerRequest(request);
},
onLog: (message) => {
if (this.isStale(generation)) return;
this.currentHandlers().onLog(message);
this.handlers.onLog(message);
},
onExit: () => {
if (this.isStale(generation)) return;
this.state = { kind: "disconnected", generation };
this.currentHandlers().onExit();
this.handlers.onExit();
},
});
const promise = client
@ -115,9 +111,4 @@ export class ConnectionManager {
private isStale(generation: number): boolean {
return generation !== this.state.generation;
}
private currentHandlers(): ConnectionManagerHandlers {
if (!this.handlers) throw new Error("ConnectionManager handlers have not been attached.");
return this.handlers;
}
}

View file

@ -0,0 +1,80 @@
import type { App } from "obsidian";
import type { CodexInput } from "../../../../domain/chat/input";
import { MessageStreamScrollBridge } from "../../panel/surface/message-stream-scroll";
import { currentModel, runtimeConfigOrDefault } from "../../runtime/effective";
import { runtimeSnapshotForChatState } from "../../runtime/snapshot";
import { activeTurnId, type ChatStateStore } from "../../state/reducer";
import type { ComposerMetaViewModel } from "../../ui/composer";
import type { ChatPanelComposerShellState } from "../../ui/shell-state";
import type { CodexChatHost } from "../../chat-host";
import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions";
import { ChatComposerController } from "./controller";
export interface ConversationComposerContext {
app: App;
plugin: CodexChatHost;
stateStore: ChatStateStore;
viewId: string;
surface: {
composerPlaceholder: (state: ChatPanelComposerShellState) => string;
composerMetaViewModel: (state: ChatPanelComposerShellState) => ComposerMetaViewModel;
};
liveState: {
refresh: () => void;
};
}
export interface ConversationComposerRefs {
runtimeSettings: ChatRuntimeSettingsActions;
}
export interface ConversationComposerParts {
controller: ChatComposerController;
scrollBridge: MessageStreamScrollBridge;
codexInput: (text: string) => CodexInput;
setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void;
}
export function createConversationComposer(
context: ConversationComposerContext,
refs: ConversationComposerRefs,
): ConversationComposerParts {
const { app, plugin, stateStore, viewId, surface, liveState } = context;
const scrollBridge = new MessageStreamScrollBridge();
const controller = new ChatComposerController({
app,
stateStore,
viewId,
sendShortcut: () => plugin.settings.sendShortcut,
scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges,
canInterrupt: (state) => {
return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state));
},
composerPlaceholder: surface.composerPlaceholder,
composerMeta: surface.composerMetaViewModel,
currentModelForSuggestions: () => {
const current = stateStore.getState();
return currentModel(runtimeSnapshotForChatState(current), runtimeConfigOrDefault(current.connection.runtimeConfig));
},
threadScrollFromComposer: (action) => {
scrollBridge.scrollFromComposer(action);
},
togglePlan: () => void refs.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
toggleFast: () => void refs.runtimeSettings.toggleFastMode(),
onDraftChange: liveState.refresh,
onHeightChange: () => {
scrollBridge.repinMessageStreamToBottomIfPinned();
},
});
return {
controller,
scrollBridge,
codexInput: (text) => controller.codexInput(text),
setDraft: (text, options) => {
controller.setDraft(text, options);
},
};
}

View file

@ -30,6 +30,7 @@ export interface ChatComposerControllerOptions {
composerPlaceholder: (state: ChatPanelComposerShellState) => string;
composerMeta: (state: ChatPanelComposerShellState) => ComposerMetaViewModel;
currentModelForSuggestions: () => string | null;
threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void;
togglePlan: () => void;
toggleAutoReview: () => void;
toggleFast: () => void;
@ -37,23 +38,17 @@ export interface ChatComposerControllerOptions {
onHeightChange: () => void;
}
export interface ChatComposerActionHandlers {
export interface ChatComposerRenderActions {
submit: () => void;
threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void;
}
export class ChatComposerController {
private composer: HTMLTextAreaElement | null = null;
private noteCandidatesCache: { sourcePath: string; notes: NoteCandidate[] } | null = null;
private noteEventsRegistered = false;
private actionHandlers: ChatComposerActionHandlers | null = null;
constructor(private readonly options: ChatComposerControllerOptions) {}
setActionHandlers(handlers: ChatComposerActionHandlers): void {
this.actionHandlers = handlers;
}
private get state(): ChatState {
return this.options.stateStore.getState();
}
@ -78,7 +73,7 @@ export class ChatComposerController {
registerEvent(this.options.app.vault.on("modify", invalidate));
}
renderState(state: ChatPanelComposerShellState): ComposerShellProps {
renderState(state: ChatPanelComposerShellState, actions: ChatComposerRenderActions): ComposerShellProps {
return {
viewId: this.options.viewId,
draft: state.composer.draft,
@ -87,7 +82,7 @@ export class ChatComposerController {
normalPlaceholder: this.options.composerPlaceholder(state),
suggestions: state.composer.suggestions,
selectedSuggestionIndex: state.composer.suggestSelected,
callbacks: this.composerCallbacks(),
callbacks: this.composerCallbacks(actions),
meta: this.options.composerMeta(state),
onComposer: this.setComposerElement,
};
@ -178,7 +173,7 @@ export class ChatComposerController {
if (!action) return false;
event.preventDefault();
this.currentActionHandlers().threadScrollFromComposer(action);
this.options.threadScrollFromComposer(action);
return true;
}
@ -300,12 +295,7 @@ export class ChatComposerController {
return this.noteCandidatesCache.notes;
}
private currentActionHandlers(): ChatComposerActionHandlers {
if (!this.actionHandlers) throw new Error("ChatComposerController action handlers have not been attached.");
return this.actionHandlers;
}
private composerCallbacks(): ComposerCallbacks {
private composerCallbacks(actions: ChatComposerRenderActions): ComposerCallbacks {
return {
onInput: (value) => {
this.handleInput(value);
@ -322,11 +312,11 @@ export class ChatComposerController {
}
if (isComposerSendKey(event, this.options.sendShortcut())) {
event.preventDefault();
this.currentActionHandlers().submit();
actions.submit();
}
},
onSendOrInterrupt: () => {
this.currentActionHandlers().submit();
actions.submit();
},
onHeightChange: () => {
this.options.onHeightChange();

View file

@ -1,30 +1,24 @@
import type { App, Component } from "obsidian";
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatServerThreadActions } from "../connection/server-actions/threads";
import { ChatComposerController } from "./composer/controller";
import { activeTurnId, type ChatStateStore } from "../state/reducer";
import { type ChatStateStore } from "../state/reducer";
import type { ChatReconnectActions } from "../connection/reconnect-actions";
import { PendingRequestController } from "./pending-requests/controller";
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
import { createComposerSubmitActions } from "./turns/composer-submit-actions";
import { createPlanImplementation } from "./turns/plan-implementation";
import { createSlashCommandHandler } from "./turns/slash-command-handler";
import { TurnSubmissionController } from "./turns/turn-submission-controller";
import type { ChatThreadActions } from "../threads/action-context";
import type { GoalActions } from "../threads/goal-actions";
import type { HistoryController } from "../threads/history-controller";
import type { ChatInboundController } from "../protocol/inbound/controller";
import { currentModel, runtimeConfigOrDefault } from "../runtime/effective";
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import { MessageStreamScrollBridge } from "../panel/surface/message-stream-scroll";
import type { DisplayDetailSection } from "../display/types";
import type { ChatMessageScrollIntentState } from "../ui/message-stream/scroll-intent-state";
import type { ComposerMetaViewModel } from "../ui/composer";
import type { ChatPanelComposerShellState } from "../ui/shell-state";
import type { CodexChatHost } from "../chat-host";
import { createConversationComposer } from "./composer/composition";
import { createConversationMessageStreamPresenter } from "./message-stream/composition";
import { createConversationTurnActions } from "./turns/composition";
interface ConversationControllersContext {
interface ConversationPartsContext {
obsidian: {
app: App;
owner: Component;
@ -74,8 +68,8 @@ interface ConversationControllersContext {
};
}
export function createConversationControllers(
context: ConversationControllersContext,
export function createConversationParts(
context: ConversationPartsContext,
refs: {
controller: ChatInboundController;
serverThreads: ChatServerThreadActions;
@ -90,41 +84,27 @@ export function createConversationControllers(
const { app, owner, viewId } = context.obsidian;
const stateStore = state.stateStore;
const currentClient = client.getClient;
const { messageScrollIntent } = lifecycle;
const messageStreamScrollBridge = new MessageStreamScrollBridge();
const composerController = new ChatComposerController({
app,
stateStore,
viewId,
sendShortcut: () => plugin.settings.sendShortcut,
scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges,
canInterrupt: (state) => {
return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state));
const composer = createConversationComposer(
{
app,
plugin,
stateStore,
viewId,
surface: {
composerPlaceholder: surface.composerPlaceholder,
composerMetaViewModel: surface.composerMetaViewModel,
},
liveState: {
refresh: liveState.refresh,
},
},
composerPlaceholder: surface.composerPlaceholder,
composerMeta: surface.composerMetaViewModel,
currentModelForSuggestions: () => {
const current = stateStore.getState();
return currentModel(runtimeSnapshotForChatState(current), runtimeConfigOrDefault(current.connection.runtimeConfig));
{
runtimeSettings: refs.runtimeSettings,
},
togglePlan: () => void refs.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
toggleFast: () => void refs.runtimeSettings.toggleFastMode(),
onDraftChange: liveState.refresh,
onHeightChange: () => {
messageStreamScrollBridge.repinMessageStreamToBottomIfPinned();
},
});
const codexInput = (text: string) => composerController.codexInput(text);
const setComposerDraft = (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => {
composerController.setDraft(text, options);
};
const startThread = (preview?: string) => refs.serverThreads.startThread(preview);
const startThreadForGoal = async (objective: string) => {
const response = await refs.serverThreads.startThread(objective, { syncGoal: false });
return response?.threadId ?? null;
};
);
const composerController = composer.controller;
const messageStreamScrollBridge = composer.scrollBridge;
const pendingRequests = new PendingRequestController({
stateStore,
@ -133,125 +113,63 @@ export function createConversationControllers(
refreshLiveState: liveState.refresh,
});
const turnSubmission = new TurnSubmissionController({
stateStore,
vaultPath: plugin.vaultPath,
currentClient,
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
startThread,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
resetThreadTurnPresence: thread.resetTurnPresence,
applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(),
codexInput,
setDraft: setComposerDraft,
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
});
const slashCommands = createSlashCommandHandler({
stateStore,
currentClient,
codexInput,
startNewThread: thread.startNewThread,
startThreadForGoal,
resumeThread: thread.selectThread,
forkThread: (threadId) => refs.threadActions.forkThread(threadId),
rollbackThread: (threadId) => refs.threadActions.rollbackThread(threadId),
compactThread: (threadId) => refs.threadActions.compactThread(threadId),
archiveThread: (threadId) => refs.threadActions.archiveThread(threadId),
renameThread: (threadId, name) => refs.threadActions.renameThread(threadId, name).then(() => undefined),
reconnect: () => refs.reconnectActions.reconnectPanel(),
toggleFastMode: () => refs.runtimeSettings.toggleFastMode(),
toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
requestModel: (model) => refs.runtimeSettings.requestModel(model),
resetModelToConfig: () => refs.runtimeSettings.resetModelToConfig(),
requestReasoningEffort: (effort) => refs.runtimeSettings.requestReasoningEffort(effort),
resetReasoningEffortToConfig: () => refs.runtimeSettings.resetReasoningEffortToConfig(),
activeGoal: () => refs.goals.activeGoal(),
setGoalObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget),
setGoalStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus),
clearGoal: (threadId) => refs.goals.clear(threadId),
addSystemMessage: status.addSystemMessage,
addStructuredSystemMessage: status.addStructuredSystemMessage,
setStatus: status.set,
statusSummaryLines: runtime.statusSummaryLines,
connectionDiagnosticDetails: runtime.connectionDiagnosticDetails,
mcpStatusLines: runtime.mcpStatusLines,
modelStatusLines: runtime.modelStatusLines,
effortStatusLines: runtime.effortStatusLines,
});
const planImplementation = createPlanImplementation({
stateStore,
currentClient,
ensureConnected: client.ensureConnected,
sendTurnText: (text) => turnSubmission.sendTurnText(text),
requestDefaultCollaborationModeForNextTurn: () => {
refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn();
},
});
const messageStreamPresenter = new MessageStreamPresenter({
obsidian: {
app,
owner,
},
state: {
store: stateStore,
},
workspace: {
const turnActions = createConversationTurnActions(
{
vaultPath: plugin.vaultPath,
},
scroll: {
consumeIntent: () => messageScrollIntent.consumeIntent(),
registerVirtualizer: messageStreamScrollBridge.registerVirtualizer,
dispose: () => {
messageStreamScrollBridge.dispose();
stateStore,
client: {
currentClient,
ensureConnected: client.ensureConnected,
},
status,
runtime,
thread,
composer: {
codexInput: composer.codexInput,
trimmedDraft: () => composerController.trimmedDraft,
setDraft: composer.setDraft,
},
scroll: {
followBottom: scroll.followBottom,
},
},
history: {
loadOlderTurns: () => void refs.history.loadOlder(),
{
serverThreads: refs.serverThreads,
runtimeSettings: refs.runtimeSettings,
threadActions: refs.threadActions,
reconnectActions: refs.reconnectActions,
goals: refs.goals,
},
actions: {
rollbackThread: (threadId) => void refs.threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void refs.threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
implementPlan: (item) => void planImplementation.implement(item),
openTurnDiff: (state) => void plugin.openTurnDiff(state),
);
const messageStreamPresenter = createConversationMessageStreamPresenter(
{
obsidian: {
app,
owner,
},
plugin,
stateStore,
lifecycle,
scroll: {
bridge: messageStreamScrollBridge,
},
surface: {
pendingRequestsSignature: surface.pendingRequestsSignature,
},
},
requests: {
pendingSignature: surface.pendingRequestsSignature,
pendingSnapshot: () => pendingRequests.snapshot(),
pendingActions: () => pendingRequests.actions(),
consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(),
{
history: refs.history,
threadActions: refs.threadActions,
pendingRequests,
planImplementation: turnActions.planImplementation,
},
});
const composerSubmit = createComposerSubmitActions({
stateStore,
composer: composerController,
slashCommands,
turnSubmission,
connection: {
currentClient,
ensureConnected: client.ensureConnected,
},
status: {
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
},
scroll: {
followBottom: scroll.followBottom,
},
});
composerController.setActionHandlers({
submit: () => void composerSubmit.submit(),
threadScrollFromComposer: (action) => {
messageStreamScrollBridge.scrollFromComposer(action);
},
});
);
return {
pendingRequests,
messageStreamPresenter,
composerController,
composerSubmit,
composerSubmit: turnActions.composerSubmit,
};
}

View file

@ -0,0 +1,77 @@
import type { App, Component } from "obsidian";
import type { CodexChatHost } from "../../chat-host";
import type { DisplayItem } from "../../display/types";
import { MessageStreamPresenter } from "../../panel/surface/message-stream-presenter";
import type { MessageStreamScrollBridge } from "../../panel/surface/message-stream-scroll";
import type { ChatStateStore } from "../../state/reducer";
import type { ChatThreadActions } from "../../threads/action-context";
import type { HistoryController } from "../../threads/history-controller";
import type { ChatMessageScrollIntentState } from "../../ui/message-stream/scroll-intent-state";
import type { PendingRequestController } from "../pending-requests/controller";
import type { PlanImplementation } from "../turns/plan-implementation";
export interface ConversationMessageStreamContext {
obsidian: {
app: App;
owner: Component;
};
plugin: CodexChatHost;
stateStore: ChatStateStore;
lifecycle: {
messageScrollIntent: ChatMessageScrollIntentState;
};
scroll: {
bridge: MessageStreamScrollBridge;
};
surface: {
pendingRequestsSignature: () => string;
};
}
export interface ConversationMessageStreamRefs {
history: HistoryController;
threadActions: ChatThreadActions;
pendingRequests: PendingRequestController;
planImplementation: PlanImplementation;
}
export function createConversationMessageStreamPresenter(
context: ConversationMessageStreamContext,
refs: ConversationMessageStreamRefs,
): MessageStreamPresenter {
const { obsidian, plugin, stateStore, lifecycle, scroll, surface } = context;
const { messageScrollIntent } = lifecycle;
const scrollBridge = scroll.bridge;
return new MessageStreamPresenter({
obsidian,
state: {
store: stateStore,
},
workspace: {
vaultPath: plugin.vaultPath,
},
scroll: {
consumeIntent: () => messageScrollIntent.consumeIntent(),
registerVirtualizer: scrollBridge.registerVirtualizer,
dispose: () => {
scrollBridge.dispose();
},
},
history: {
loadOlderTurns: () => void refs.history.loadOlder(),
},
actions: {
rollbackThread: (threadId) => void refs.threadActions.rollbackThread(threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => void refs.threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
implementPlan: (item: DisplayItem) => void refs.planImplementation.implement(item),
openTurnDiff: (state) => void plugin.openTurnDiff(state),
},
requests: {
pendingSignature: surface.pendingRequestsSignature,
pendingSnapshot: () => refs.pendingRequests.snapshot(),
pendingActions: () => refs.pendingRequests.actions(),
consumePendingAutoFocus: () => refs.pendingRequests.consumeAutoFocus(),
},
});
}

View file

@ -0,0 +1,155 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { CodexInput } from "../../../../domain/chat/input";
import type { ChatServerThreadActions } from "../../connection/server-actions/threads";
import type { ChatReconnectActions } from "../../connection/reconnect-actions";
import type { DisplayDetailSection } from "../../display/types";
import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions";
import type { ChatStateStore } from "../../state/reducer";
import type { ChatThreadActions } from "../../threads/action-context";
import type { GoalActions } from "../../threads/goal-actions";
import { createComposerSubmitActions, type ComposerSubmitActions } from "./composer-submit-actions";
import { createPlanImplementation, type PlanImplementation } from "./plan-implementation";
import { createSlashCommandHandler } from "./slash-command-handler";
import { TurnSubmissionController } from "./turn-submission-controller";
export interface ConversationTurnActionsContext {
vaultPath: string;
stateStore: ChatStateStore;
client: {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
};
runtime: {
connectionDiagnosticDetails: () => DisplayDetailSection[];
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];
mcpStatusLines: () => Promise<string[]>;
};
thread: {
ensureRestoredThreadLoaded: () => Promise<boolean>;
startNewThread: () => Promise<void>;
selectThread: (threadId: string) => Promise<void>;
notifyIdentityChanged: () => void;
resetTurnPresence: (hadTurns: boolean) => void;
};
composer: {
codexInput: (text: string) => CodexInput;
trimmedDraft: () => string;
setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void;
};
scroll: {
followBottom: () => void;
};
}
export interface ConversationTurnActionsRefs {
serverThreads: ChatServerThreadActions;
runtimeSettings: ChatRuntimeSettingsActions;
threadActions: ChatThreadActions;
reconnectActions: ChatReconnectActions;
goals: GoalActions;
}
export interface ConversationTurnActions {
planImplementation: PlanImplementation;
composerSubmit: ComposerSubmitActions;
}
export function createConversationTurnActions(
context: ConversationTurnActionsContext,
refs: ConversationTurnActionsRefs,
): ConversationTurnActions {
const { vaultPath, stateStore, client, status, runtime, thread, composer, scroll } = context;
const turnSubmission = new TurnSubmissionController({
stateStore,
vaultPath,
currentClient: client.currentClient,
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
startThread: (preview) => refs.serverThreads.startThread(preview),
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
resetThreadTurnPresence: thread.resetTurnPresence,
applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(),
codexInput: composer.codexInput,
setDraft: composer.setDraft,
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
});
const slashCommands = createSlashCommandHandler({
stateStore,
currentClient: client.currentClient,
codexInput: composer.codexInput,
startNewThread: thread.startNewThread,
startThreadForGoal: (objective) => startThreadForGoal(refs.serverThreads, objective),
resumeThread: thread.selectThread,
forkThread: (threadId) => refs.threadActions.forkThread(threadId),
rollbackThread: (threadId) => refs.threadActions.rollbackThread(threadId),
compactThread: (threadId) => refs.threadActions.compactThread(threadId),
archiveThread: (threadId, saveMarkdown) => refs.threadActions.archiveThread(threadId, saveMarkdown),
renameThread: (threadId, name) => refs.threadActions.renameThread(threadId, name).then(() => undefined),
reconnect: () => refs.reconnectActions.reconnectPanel(),
toggleFastMode: () => refs.runtimeSettings.toggleFastMode(),
toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
requestModel: (model) => refs.runtimeSettings.requestModel(model),
resetModelToConfig: () => refs.runtimeSettings.resetModelToConfig(),
requestReasoningEffort: (effort) => refs.runtimeSettings.requestReasoningEffort(effort),
resetReasoningEffortToConfig: () => refs.runtimeSettings.resetReasoningEffortToConfig(),
activeGoal: () => refs.goals.activeGoal(),
setGoalObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget),
setGoalStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus),
clearGoal: (threadId) => refs.goals.clear(threadId),
addSystemMessage: status.addSystemMessage,
addStructuredSystemMessage: status.addStructuredSystemMessage,
setStatus: status.set,
statusSummaryLines: runtime.statusSummaryLines,
connectionDiagnosticDetails: runtime.connectionDiagnosticDetails,
mcpStatusLines: runtime.mcpStatusLines,
modelStatusLines: runtime.modelStatusLines,
effortStatusLines: runtime.effortStatusLines,
});
const planImplementation = createPlanImplementation({
stateStore,
currentClient: client.currentClient,
ensureConnected: client.ensureConnected,
sendTurnText: (text) => turnSubmission.sendTurnText(text),
requestDefaultCollaborationModeForNextTurn: () => {
refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn();
},
});
const composerSubmit = createComposerSubmitActions({
stateStore,
composer: {
get trimmedDraft() {
return composer.trimmedDraft();
},
setDraft: composer.setDraft,
},
slashCommands,
turnSubmission,
connection: {
currentClient: client.currentClient,
ensureConnected: client.ensureConnected,
},
status: {
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
},
scroll,
});
return {
planImplementation,
composerSubmit,
};
}
async function startThreadForGoal(actions: ChatServerThreadActions, objective: string): Promise<string | null> {
const response = await actions.startThread(objective, { syncGoal: false });
return response?.threadId ?? null;
}

View file

@ -30,7 +30,11 @@ import { runtimeSnapshotForShellState } from "./runtime-snapshot";
type ComposerMetaState = Pick<ChatState, "connection" | "runtime">;
export interface ChatPanelComposerController {
renderState(state: ChatPanelComposerShellState): ComposerShellProps;
renderState(state: ChatPanelComposerShellState, actions: ChatPanelComposerActions): ComposerShellProps;
}
export interface ChatPanelComposerActions {
submit: () => void;
}
export interface RuntimeComposerChoicesInput {
@ -45,9 +49,15 @@ export function composerPlaceholder(threadName: string | null): string {
return threadName ? `Ask Codex to work on “${threadName}”...` : "Ask Codex to work on this task...";
}
export function ChatPanelComposer({ controller }: { controller: ChatPanelComposerController }): UiNode {
export function ChatPanelComposer({
controller,
actions,
}: {
controller: ChatPanelComposerController;
actions: ChatPanelComposerActions;
}): UiNode {
const state = composerStateFromShellState(useChatPanelShellState());
return h(ComposerShell, controller.renderState(state));
return h(ComposerShell, controller.renderState(state, actions));
}
export function chatPanelComposerPlaceholder(surface: ChatPanelComposerSurface, state: ChatPanelComposerShellState): string {

View file

@ -1,6 +1,6 @@
import type { CodexPanelSettings } from "../../../../settings/model";
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
import { type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer";
import type { ChatStateStore } from "../../state/reducer";
import type { ChatConnectionController } from "../../connection/connection-controller";
import type { ChatReconnectActions } from "../../connection/reconnect-actions";
import type { ChatInboundController } from "../../protocol/inbound/controller";
@ -12,6 +12,8 @@ import type { SelectionActions } from "../../threads/selection-actions";
import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions";
import type { GoalActions } from "../../threads/goal-actions";
import type { RestoredThreadTitleSnapshot, ChatPanelSurface } from "./model";
import { createChatPanelGoalSurface } from "./goal-surface";
import { createChatPanelToolbarSurface } from "./toolbar-surface";
export interface ChatPanelSurfaceHost {
settings: CodexPanelSettings;
@ -36,108 +38,37 @@ export interface ChatPanelSurfaceDependencies {
}
export function createChatPanelSurface(host: ChatPanelSurfaceHost, deps: ChatPanelSurfaceDependencies): ChatPanelSurface {
const dispatch = (action: ChatAction): void => {
host.stateStore.dispatch(action);
};
const startGoalEditing = ({ closeToolbarPanel = false }: { closeToolbarPanel?: boolean } = {}): void => {
if (closeToolbarPanel) dispatch({ type: "ui/panel-set", panel: null });
const goal = host.stateStore.getState().activeThread.goal;
dispatch({
type: "ui/goal-editor-started",
threadId: goal?.threadId ?? null,
objective: goal?.objective ?? "",
tokenBudget: goal?.tokenBudget ?? null,
});
};
return {
toolbar: {
state: {
connected: () => deps.connection.isConnected(),
nowMs: () => Date.now(),
toolbar: createChatPanelToolbarSurface(
{
settings: host.settings,
vaultPath: host.vaultPath,
stateStore: host.stateStore,
startNewThread: host.startNewThread,
},
settings: {
vaultPath: () => host.vaultPath,
configuredCommand: () => host.settings.codexPath,
archiveExportEnabled: () => host.settings.archiveExportEnabled,
{
connection: deps.connection,
connectionController: deps.connectionController,
reconnectActions: deps.reconnectActions,
inboundController: deps.inboundController,
threadActions: deps.threadActions,
toolbarPanels: deps.toolbarPanels,
rename: deps.rename,
selection: deps.selection,
},
actions: {
toolbar: {
startNewThread: () => {
void host.startNewThread();
},
toggleChatActions: () => {
deps.toolbarPanels.toggleChatActions();
},
compactConversation: () => {
void compactConversation(host.stateStore.getState(), deps);
},
setGoal: () => {
startGoalEditing({ closeToolbarPanel: true });
},
toggleHistory: () => {
deps.toolbarPanels.toggleHistory();
},
toggleStatusPanel: () => {
deps.toolbarPanels.toggleStatus();
},
connect: () => {
void deps.reconnectActions.reconnectPanel();
},
refreshStatus: () => {
void deps.connectionController.refreshStatusPanel();
},
resumeThread: (threadId) => {
void deps.selection.selectThreadFromToolbar(threadId);
},
startArchiveThread: (threadId) => {
deps.toolbarPanels.startArchive(threadId);
},
archiveThread: (threadId, saveMarkdown) => {
void deps.toolbarPanels.archiveThread(threadId, saveMarkdown);
},
startRenameThread: (threadId) => {
deps.rename.start(threadId);
},
updateRenameDraft: (threadId, value) => {
deps.rename.updateDraft(threadId, value);
},
saveRenameThread: (threadId, value) => {
void deps.rename.save(threadId, value);
},
cancelRenameThread: (threadId) => {
deps.rename.cancel(threadId);
},
autoNameThread: (threadId) => {
void deps.rename.autoNameDraft(threadId);
},
},
),
goal: createChatPanelGoalSurface(
{
settings: host.settings,
stateStore: host.stateStore,
},
},
goal: {
settings: {
sendShortcut: () => host.settings.sendShortcut,
{
connectionController: deps.connectionController,
inboundController: deps.inboundController,
serverThreads: deps.serverThreads,
goals: deps.goals,
},
actions: {
goal: {
saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), deps, objective, tokenBudget),
setStatus: (threadId, status) => deps.goals.setStatus(threadId, status),
clear: (threadId) => deps.goals.clear(threadId),
startEditing: (threadId, objective, tokenBudget) => {
dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
},
updateObjectiveDraft: (objective) => {
dispatch({ type: "ui/goal-editor-draft-updated", objective });
},
setObjectiveExpanded: (threadId, expanded) => {
dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded });
},
closeEditor: () => {
dispatch({ type: "ui/goal-editor-closed" });
},
},
},
},
),
composer: {
thread: {
restoredPlaceholder: host.restoredThreadPlaceholder,
@ -150,33 +81,3 @@ export function createChatPanelSurface(host: ChatPanelSurfaceHost, deps: ChatPan
},
};
}
async function compactConversation(state: ChatState, deps: ChatPanelSurfaceDependencies): Promise<void> {
const threadId = state.activeThread.id;
if (!threadId) {
deps.inboundController.addSystemMessage("No active thread to compact.");
return;
}
await deps.threadActions.compactThread(threadId);
}
async function saveGoalObjective(
state: ChatState,
deps: ChatPanelSurfaceDependencies,
objective: string,
tokenBudget: number | null,
): Promise<void> {
let threadId = state.activeThread.id;
if (!threadId) {
try {
await deps.connectionController.ensureConnected();
const response = await deps.serverThreads.startThread(objective, { syncGoal: false });
threadId = response?.threadId ?? null;
} catch (error) {
deps.inboundController.addSystemMessage(error instanceof Error ? error.message : String(error));
return;
}
}
if (!threadId) return;
void deps.goals.setObjective(threadId, objective, tokenBudget);
}

View file

@ -0,0 +1,71 @@
import type { CodexPanelSettings } from "../../../../settings/model";
import type { ChatConnectionController } from "../../connection/connection-controller";
import type { ChatInboundController } from "../../protocol/inbound/controller";
import type { ChatServerThreadActions } from "../../connection/server-actions/threads";
import type { GoalActions } from "../../threads/goal-actions";
import type { ChatAction, ChatState, ChatStateStore } from "../../state/reducer";
import type { ChatPanelGoalSurface } from "./model";
export interface ChatPanelGoalSurfaceHost {
settings: CodexPanelSettings;
stateStore: ChatStateStore;
}
export interface ChatPanelGoalSurfaceDependencies {
connectionController: ChatConnectionController;
inboundController: ChatInboundController;
serverThreads: ChatServerThreadActions;
goals: GoalActions;
}
export function createChatPanelGoalSurface(host: ChatPanelGoalSurfaceHost, deps: ChatPanelGoalSurfaceDependencies): ChatPanelGoalSurface {
const dispatch = (action: ChatAction): void => {
host.stateStore.dispatch(action);
};
return {
settings: {
sendShortcut: () => host.settings.sendShortcut,
},
actions: {
goal: {
saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), deps, objective, tokenBudget),
setStatus: (threadId, status) => deps.goals.setStatus(threadId, status),
clear: (threadId) => deps.goals.clear(threadId),
startEditing: (threadId, objective, tokenBudget) => {
dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget });
},
updateObjectiveDraft: (objective) => {
dispatch({ type: "ui/goal-editor-draft-updated", objective });
},
setObjectiveExpanded: (threadId, expanded) => {
dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded });
},
closeEditor: () => {
dispatch({ type: "ui/goal-editor-closed" });
},
},
},
};
}
async function saveGoalObjective(
state: ChatState,
deps: ChatPanelGoalSurfaceDependencies,
objective: string,
tokenBudget: number | null,
): Promise<void> {
let threadId = state.activeThread.id;
if (!threadId) {
try {
await deps.connectionController.ensureConnected();
const response = await deps.serverThreads.startThread(objective, { syncGoal: false });
threadId = response?.threadId ?? null;
} catch (error) {
deps.inboundController.addSystemMessage(error instanceof Error ? error.message : String(error));
return;
}
}
if (!threadId) return;
void deps.goals.setObjective(threadId, objective, tokenBudget);
}

View file

@ -0,0 +1,117 @@
import type { CodexPanelSettings } from "../../../../settings/model";
import type { ConnectionManager } from "../../../../app-server/connection/connection-manager";
import type { ChatConnectionController } from "../../connection/connection-controller";
import type { ChatReconnectActions } from "../../connection/reconnect-actions";
import type { ChatInboundController } from "../../protocol/inbound/controller";
import type { ChatThreadActions } from "../../threads/action-context";
import type { ToolbarPanelActions } from "../toolbar-actions";
import type { RenameController } from "../../threads/rename-controller";
import type { SelectionActions } from "../../threads/selection-actions";
import type { ChatStateStore } from "../../state/reducer";
import type { ChatPanelToolbarSurface } from "./model";
export interface ChatPanelToolbarSurfaceHost {
settings: CodexPanelSettings;
vaultPath: string;
stateStore: ChatStateStore;
startNewThread: () => Promise<void>;
}
export interface ChatPanelToolbarSurfaceDependencies {
connection: ConnectionManager;
connectionController: ChatConnectionController;
reconnectActions: ChatReconnectActions;
inboundController: ChatInboundController;
threadActions: ChatThreadActions;
toolbarPanels: ToolbarPanelActions;
rename: RenameController;
selection: SelectionActions;
}
export function createChatPanelToolbarSurface(
host: ChatPanelToolbarSurfaceHost,
deps: ChatPanelToolbarSurfaceDependencies,
): ChatPanelToolbarSurface {
return {
state: {
connected: () => deps.connection.isConnected(),
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => host.vaultPath,
configuredCommand: () => host.settings.codexPath,
archiveExportEnabled: () => host.settings.archiveExportEnabled,
},
actions: {
toolbar: {
startNewThread: () => {
void host.startNewThread();
},
toggleChatActions: () => {
deps.toolbarPanels.toggleChatActions();
},
compactConversation: () => {
void compactConversation(host.stateStore.getState(), deps);
},
setGoal: () => {
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
const goal = host.stateStore.getState().activeThread.goal;
host.stateStore.dispatch({
type: "ui/goal-editor-started",
threadId: goal?.threadId ?? null,
objective: goal?.objective ?? "",
tokenBudget: goal?.tokenBudget ?? null,
});
},
toggleHistory: () => {
deps.toolbarPanels.toggleHistory();
},
toggleStatusPanel: () => {
deps.toolbarPanels.toggleStatus();
},
connect: () => {
void deps.reconnectActions.reconnectPanel();
},
refreshStatus: () => {
void deps.connectionController.refreshStatusPanel();
},
resumeThread: (threadId) => {
void deps.selection.selectThreadFromToolbar(threadId);
},
startArchiveThread: (threadId) => {
deps.toolbarPanels.startArchive(threadId);
},
archiveThread: (threadId, saveMarkdown) => {
void deps.toolbarPanels.archiveThread(threadId, saveMarkdown);
},
startRenameThread: (threadId) => {
deps.rename.start(threadId);
},
updateRenameDraft: (threadId, value) => {
deps.rename.updateDraft(threadId, value);
},
saveRenameThread: (threadId, value) => {
void deps.rename.save(threadId, value);
},
cancelRenameThread: (threadId) => {
deps.rename.cancel(threadId);
},
autoNameThread: (threadId) => {
void deps.rename.autoNameDraft(threadId);
},
},
},
};
}
async function compactConversation(
state: ReturnType<ChatStateStore["getState"]>,
deps: Pick<ChatPanelToolbarSurfaceDependencies, "inboundController" | "threadActions">,
): Promise<void> {
const threadId = state.activeThread.id;
if (!threadId) {
deps.inboundController.addSystemMessage("No active thread to compact.");
return;
}
await deps.threadActions.compactThread(threadId);
}

View file

@ -16,7 +16,7 @@ import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions }
import { createChatServerMetadataActions, type ChatServerMetadataActions } from "./connection/server-actions/metadata";
import { createChatServerThreadActions, type ChatServerThreadActions } from "./connection/server-actions/threads";
import type { ChatComposerController } from "./conversation/composer/controller";
import { createConversationControllers } from "./conversation/composition";
import { createConversationParts } from "./conversation/composition";
import type { PendingRequestController } from "./conversation/pending-requests/controller";
import type { ComposerSubmitActions } from "./conversation/turns/composer-submit-actions";
import { createStructuredSystemItem, createSystemItem } from "./display/items/system";
@ -63,7 +63,7 @@ import type { RenameController } from "./threads/rename-controller";
import type { RestorationController } from "./threads/restoration-controller";
import type { ResumeController } from "./threads/resume-controller";
import type { SelectionActions } from "./threads/selection-actions";
import { createThreadSelectionActions, createThreadServices } from "./threads/services";
import { createThreadParts, createThreadSelectionActions } from "./threads/composition";
import type { MessageStreamPresenter } from "./panel/surface/message-stream-presenter";
import { pendingRequestsSignature } from "./conversation/pending-requests/signatures";
import { createChatPanelSurface } from "./panel/surface/create-surface";
@ -292,16 +292,29 @@ export class ChatPanelSession {
}
private createSessionParts(): ChatPanelSessionParts {
const connection = new ConnectionManager(() => this.environment.plugin.settings.codexPath, this.environment.plugin.vaultPath);
const connection = new ConnectionManager(() => this.environment.plugin.settings.codexPath, this.environment.plugin.vaultPath, {
onNotification: (notification) => {
this.parts.inbound.controller.handleNotification(notification);
this.refreshLiveState();
},
onServerRequest: (request) => {
this.parts.inbound.controller.handleServerRequest(request);
this.refreshLiveState();
},
onLog: (message) => {
this.parts.inbound.controller.handleAppServerLog(message);
},
onExit: () => {
this.parts.connection.controller.handleExit();
},
});
const sideEffects = this.createSideEffects();
let connectionController: ChatConnectionController | null = null;
let selection: SelectionActions | null = null;
const currentClient = () => this.client;
const ensureConnected = () => requireSessionPart(connectionController, "connection controller").ensureConnected();
const refreshThreads = () => requireSessionPart(connectionController, "connection controller").refreshThreads();
const refreshSkills = (forceReload?: boolean) =>
requireSessionPart(connectionController, "connection controller").refreshSkills(forceReload);
const selectThread = (threadId: string) => requireSessionPart(selection, "selection actions").selectThread(threadId);
const ensureConnected = () => this.parts.connection.controller.ensureConnected();
const refreshThreads = () => this.parts.connection.controller.refreshThreads();
const refreshSkills = (forceReload?: boolean) => this.parts.connection.controller.refreshSkills(forceReload);
const selectThread = (threadId: string) => this.parts.thread.selection.selectThread(threadId);
const resumeRestoredThread = (threadId: string) => this.parts.thread.resume.resumeThread(threadId);
const runtimeSettings = createChatRuntimeSettingsActions({
stateStore: this.stateStore,
@ -310,7 +323,7 @@ export class ChatPanelSession {
collaborationModeLabel: () => this.collaborationModeLabel(),
addSystemMessage: sideEffects.status.addSystemMessage,
});
const threadControllers = createThreadServices(
const threadParts = createThreadParts(
{
obsidian: {
archiveAdapter: this.environment.obsidian.archiveAdapter,
@ -332,6 +345,7 @@ export class ChatPanelSession {
status: sideEffects.status,
thread: {
selectThread,
resumeRestoredThread,
refreshThreads,
notifyIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
@ -359,12 +373,12 @@ export class ChatPanelSession {
connection,
},
);
const { history, actions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadControllers;
const { history, actions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadParts;
const toolbarPanels = createToolbarPanelActions({
stateStore: this.stateStore,
threadActions,
});
selection = createThreadSelectionActions(
const selection = createThreadSelectionActions(
{
plugin: this.environment.plugin,
state: {
@ -380,7 +394,7 @@ export class ChatPanelSession {
toolbarPanels.closeForThreadSelection();
},
},
).selection;
);
const reconnectActions = createChatReconnectActions({
stateStore: this.stateStore,
@ -458,7 +472,7 @@ export class ChatPanelSession {
respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result),
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
});
connectionController = new ChatConnectionController({
const connectionController = new ChatConnectionController({
stateStore: this.stateStore,
connection,
connectionWork: this.connectionWork,
@ -504,23 +518,6 @@ export class ChatPanelSession {
},
});
connection.setHandlers({
onNotification: (notification) => {
inboundController.handleNotification(notification);
this.refreshLiveState();
},
onServerRequest: (request) => {
inboundController.handleServerRequest(request);
this.refreshLiveState();
},
onLog: (message) => {
inboundController.handleAppServerLog(message);
},
onExit: () => {
requireSessionPart(connectionController, "connection controller").handleExit();
},
});
const surface = createChatPanelSurface(
{
settings: this.environment.plugin.settings,
@ -543,7 +540,7 @@ export class ChatPanelSession {
goals,
},
);
const conversationControllers = createConversationControllers(
const conversationParts = createConversationParts(
{
obsidian: {
app: this.environment.obsidian.app,
@ -614,8 +611,8 @@ export class ChatPanelSession {
history,
},
);
const { pendingRequests, composerSubmit, messageStreamPresenter } = conversationControllers;
const composerController = conversationControllers.composerController;
const { pendingRequests, composerSubmit, messageStreamPresenter } = conversationParts;
const composerController = conversationParts.composerController;
return {
connection: {
@ -770,7 +767,12 @@ export class ChatPanelSession {
toolbar: this.parts.surface.toolbar,
goal: this.parts.surface.goal,
messageStream: this.parts.render.messageStreamPresenter,
composer: this.parts.composer.controller,
composer: {
controller: this.parts.composer.controller,
actions: {
submit: () => void this.parts.composer.submission.submit(),
},
},
},
});
}
@ -895,8 +897,3 @@ export class ChatPanelSession {
return createStructuredSystemItem(`system-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, text, details);
}
}
function requireSessionPart<T>(part: T | null, name: string): T {
if (!part) throw new Error(`Chat panel session did not initialize ${name}.`);
return part;
}

View file

@ -0,0 +1,69 @@
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown";
import type { ChatStateStore } from "../state/reducer";
import type { CodexChatHost } from "../chat-host";
import { archiveThread } from "./archive-actions";
import { compactThread } from "./compact-actions";
import { forkThread, forkThreadFromTurn } from "./fork-actions";
import { renameThread } from "./rename-actions";
import { rollbackThread } from "./rollback-actions";
import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context";
export interface ThreadActionPartsContext {
obsidian: {
archiveAdapter: () => ArchiveExportAdapter;
};
plugin: CodexChatHost;
stateStore: ChatStateStore;
client: {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
notifyIdentityChanged: () => void;
};
composer: {
setText: (text: string) => void;
};
}
export function createThreadActions(context: ThreadActionPartsContext): ChatThreadActions {
const { obsidian, plugin, stateStore, client, status, thread, composer } = context;
const host: ChatThreadActionsHost = {
stateStore,
vaultPath: plugin.vaultPath,
settings: () => plugin.settings,
archiveAdapter: obsidian.archiveAdapter,
ensureConnected: client.ensureConnected,
currentClient: client.currentClient,
addSystemMessage: status.addSystemMessage,
setStatus: status.set,
setComposerText: composer.setText,
openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId),
openThreadInCurrentPanel: thread.selectThread,
notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin),
notifyThreadRenamed: (threadId, name) => {
plugin.notifyThreadRenamed(threadId, name);
},
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
refreshThreads: thread.refreshThreads,
refreshSharedThreadListFromOpenSurface: () => {
plugin.refreshSharedThreadListFromOpenSurface();
},
};
return {
compactThread: (threadId) => compactThread(host, threadId),
archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown),
forkThread: (threadId) => forkThread(host, threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource),
renameThread: (threadId, name) => renameThread(host, threadId, name),
rollbackThread: (threadId) => rollbackThread(host, threadId),
};
}

View file

@ -0,0 +1,172 @@
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown";
import { createGoalActions } from "./goal-actions";
import { createSelectionActions } from "./selection-actions";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatStateStore } from "../state/reducer";
import type { CodexChatHost } from "../chat-host";
import { createThreadNamingParts } from "./naming-parts";
import { createThreadActions } from "./action-parts";
import { createThreadLifecycleParts } from "./lifecycle-parts";
interface ThreadPartsContext {
obsidian: {
archiveAdapter: () => ArchiveExportAdapter;
};
plugin: CodexChatHost;
state: {
stateStore: ChatStateStore;
};
client: {
getClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
resumeWork: ChatResumeWorkTracker;
getOpened: () => boolean;
getClosing: () => boolean;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
resumeRestoredThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
notifyIdentityChanged: () => void;
refreshTabHeader: () => void;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
liveState: {
refresh: () => void;
};
scroll: {
preservePosition: () => void;
forceBottom: () => void;
};
composer: {
setText: (text: string) => void;
};
}
export function createThreadParts(
context: ThreadPartsContext,
refs: {
connection: ConnectionManager;
},
) {
const { obsidian, plugin, state, thread, status, liveState, scroll, client, composer, lifecycle } = context;
const stateStore = state.stateStore;
const currentClient = client.getClient;
const naming = createThreadNamingParts(
{
plugin,
stateStore,
client: {
currentClient,
ensureConnected: client.ensureConnected,
},
status: {
addSystemMessage: status.addSystemMessage,
},
},
{
connection: refs.connection,
},
);
const { rename, autoTitle, resetThreadTurnPresence } = naming;
const actions = createThreadActions({
obsidian,
plugin,
stateStore,
client: {
currentClient,
ensureConnected: client.ensureConnected,
},
status,
thread: {
selectThread: thread.selectThread,
refreshThreads: thread.refreshThreads,
notifyIdentityChanged: thread.notifyIdentityChanged,
},
composer,
});
const goals = createGoalActions({
stateStore,
currentClient,
ensureConnected: client.ensureConnected,
addSystemMessage: status.addSystemMessage,
addGoalEvent: (item) => {
stateStore.dispatch({ type: "message-stream/item-upserted", item });
},
refreshLiveState: liveState.refresh,
});
const threadLifecycle = createThreadLifecycleParts({
plugin,
stateStore,
client: {
currentClient,
ensureConnected: client.ensureConnected,
},
lifecycle,
thread: {
resumeRestoredThread: thread.resumeRestoredThread,
notifyIdentityChanged: thread.notifyIdentityChanged,
refreshTabHeader: thread.refreshTabHeader,
},
status,
liveState,
scroll,
goals,
resetThreadTurnPresence,
});
const { history, restoration, resume, identity } = threadLifecycle;
return {
history,
actions,
goals,
restoration,
resume,
identity,
rename,
autoTitle,
};
}
interface ThreadSelectionActionsContext {
plugin: CodexChatHost;
state: {
stateStore: ChatStateStore;
};
status: {
addSystemMessage: (text: string) => void;
};
thread: {
resumeThread: (threadId: string) => Promise<void>;
};
}
export function createThreadSelectionActions(
context: ThreadSelectionActionsContext,
refs: {
closeForThreadSelection: () => void;
},
) {
const { plugin, thread, status } = context;
const stateStore = context.state.stateStore;
return createSelectionActions({
stateStore,
closeForThreadSelection: () => {
refs.closeForThreadSelection();
},
focusThreadInOpenView: (threadId) => plugin.focusThreadInOpenView(threadId),
resumeThread: thread.resumeThread,
addSystemMessage: status.addSystemMessage,
});
}

View file

@ -0,0 +1,118 @@
import type { AppServerClient } from "../../../app-server/connection/client";
import { recoverRolloutTokenUsage } from "../../../app-server/services/rollout-token-usage";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatStateStore } from "../state/reducer";
import type { CodexChatHost } from "../chat-host";
import type { GoalActions } from "./goal-actions";
import { HistoryController } from "./history-controller";
import { createIdentitySync } from "./identity-sync";
import { ResumeController } from "./resume-controller";
import { RestorationController } from "./restoration-controller";
export interface ThreadLifecyclePartsContext {
plugin: CodexChatHost;
stateStore: ChatStateStore;
client: {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
resumeWork: ChatResumeWorkTracker;
getOpened: () => boolean;
getClosing: () => boolean;
};
thread: {
resumeRestoredThread: (threadId: string) => Promise<void>;
notifyIdentityChanged: () => void;
refreshTabHeader: () => void;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
liveState: {
refresh: () => void;
};
scroll: {
preservePosition: () => void;
forceBottom: () => void;
};
goals: GoalActions;
resetThreadTurnPresence: (hadTurns: boolean) => void;
}
export interface ThreadLifecycleParts {
history: HistoryController;
restoration: RestorationController;
resume: ResumeController;
identity: ReturnType<typeof createIdentitySync>;
}
export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext): ThreadLifecycleParts {
const { plugin, stateStore, client, lifecycle, thread, status, liveState, scroll, goals, resetThreadTurnPresence } = context;
const { deferredTasks, resumeWork } = lifecycle;
const history = new HistoryController({
stateStore,
currentClient: client.currentClient,
addSystemMessage: status.addSystemMessage,
keepCurrentScrollPosition: scroll.preservePosition,
showLatestPageAtBottom: scroll.forceBottom,
setThreadTurnPresence: resetThreadTurnPresence,
});
const invalidateResumeWork = () => {
resumeWork.invalidate();
history.invalidate();
};
const restoration = new RestorationController({
deferredTasks,
opened: lifecycle.getOpened,
resumeThread: thread.resumeRestoredThread,
invalidateResumeWork,
stateStore,
setStatus: status.set,
refreshTabHeader: thread.refreshTabHeader,
});
const resume = new ResumeController({
stateStore,
vaultPath: plugin.vaultPath,
resumeWork,
history,
restoration,
currentClient: client.currentClient,
ensureConnected: client.ensureConnected,
closing: lifecycle.getClosing,
resetThreadTurnPresence,
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
addSystemMessage: status.addSystemMessage,
refreshLiveState: liveState.refresh,
syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId),
recoverTokenUsageFromRollout: (path) =>
recoverRolloutTokenUsage(path, async (filePath, options) => {
const response = await client.currentClient()?.readFile(filePath, options);
return response?.dataBase64 ?? "";
}),
});
const identity = createIdentitySync({
stateStore,
restoration,
invalidateResumeWork,
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
refreshTabHeader: thread.refreshTabHeader,
refreshLiveState: liveState.refresh,
});
return {
history,
restoration,
resume,
identity,
};
}

View file

@ -0,0 +1,56 @@
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { AppServerClient } from "../../../app-server/connection/client";
import type { ChatStateStore } from "../state/reducer";
import type { CodexChatHost } from "../chat-host";
import { AutoTitleController } from "./auto-title-controller";
import { RenameController } from "./rename-controller";
export interface ThreadNamingPartsContext {
plugin: CodexChatHost;
stateStore: ChatStateStore;
client: {
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
status: {
addSystemMessage: (text: string) => void;
};
}
export interface ThreadNamingPartsRefs {
connection: ConnectionManager;
}
export interface ThreadNamingParts {
rename: RenameController;
autoTitle: AutoTitleController;
resetThreadTurnPresence: (hadTurns: boolean) => void;
}
export function createThreadNamingParts(context: ThreadNamingPartsContext, refs: ThreadNamingPartsRefs): ThreadNamingParts {
const { plugin, stateStore, client, status } = context;
const rename = new RenameController({
stateStore,
vaultPath: plugin.vaultPath,
settings: () => plugin.settings,
ensureConnected: client.ensureConnected,
currentClient: () => refs.connection.currentClient(),
addSystemMessage: status.addSystemMessage,
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
});
const autoTitle = new AutoTitleController({
stateStore,
vaultPath: plugin.vaultPath,
settings: () => plugin.settings,
currentClient: client.currentClient,
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
});
return {
rename,
autoTitle,
resetThreadTurnPresence: (hadTurns) => {
autoTitle.resetThreadTurnPresence(hadTurns);
},
};
}

View file

@ -1,242 +0,0 @@
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { AppServerClient } from "../../../app-server/connection/client";
import { recoverRolloutTokenUsage } from "../../../app-server/services/rollout-token-usage";
import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown";
import { archiveThread } from "./archive-actions";
import { AutoTitleController } from "./auto-title-controller";
import { compactThread } from "./compact-actions";
import { forkThread, forkThreadFromTurn } from "./fork-actions";
import { createGoalActions } from "./goal-actions";
import { HistoryController } from "./history-controller";
import { createIdentitySync } from "./identity-sync";
import { RenameController } from "./rename-controller";
import { renameThread } from "./rename-actions";
import { ResumeController } from "./resume-controller";
import { rollbackThread } from "./rollback-actions";
import { createSelectionActions } from "./selection-actions";
import { RestorationController } from "./restoration-controller";
import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatStateStore } from "../state/reducer";
import type { CodexChatHost } from "../chat-host";
interface ThreadServicesContext {
obsidian: {
archiveAdapter: () => ArchiveExportAdapter;
};
plugin: CodexChatHost;
state: {
stateStore: ChatStateStore;
};
client: {
getClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
resumeWork: ChatResumeWorkTracker;
getOpened: () => boolean;
getClosing: () => boolean;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
notifyIdentityChanged: () => void;
refreshTabHeader: () => void;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
liveState: {
refresh: () => void;
};
scroll: {
preservePosition: () => void;
forceBottom: () => void;
};
composer: {
setText: (text: string) => void;
};
}
export function createThreadServices(
context: ThreadServicesContext,
refs: {
connection: ConnectionManager;
},
) {
const { obsidian, plugin, state, thread, status, liveState, scroll, client, composer, lifecycle } = context;
const stateStore = state.stateStore;
const currentClient = client.getClient;
const { deferredTasks, resumeWork } = lifecycle;
const rename = new RenameController({
stateStore,
vaultPath: plugin.vaultPath,
settings: () => plugin.settings,
ensureConnected: client.ensureConnected,
currentClient: () => refs.connection.currentClient(),
addSystemMessage: status.addSystemMessage,
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
});
const autoTitle = new AutoTitleController({
stateStore,
vaultPath: plugin.vaultPath,
settings: () => plugin.settings,
currentClient,
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
});
const resetThreadTurnPresence = (hadTurns: boolean) => {
autoTitle.resetThreadTurnPresence(hadTurns);
};
const history = new HistoryController({
stateStore,
currentClient,
addSystemMessage: status.addSystemMessage,
keepCurrentScrollPosition: scroll.preservePosition,
showLatestPageAtBottom: scroll.forceBottom,
setThreadTurnPresence: resetThreadTurnPresence,
});
const invalidateResumeWork = () => {
resumeWork.invalidate();
history.invalidate();
};
const threadActionHost: ChatThreadActionsHost = {
stateStore,
vaultPath: plugin.vaultPath,
settings: () => plugin.settings,
archiveAdapter: obsidian.archiveAdapter,
ensureConnected: client.ensureConnected,
currentClient,
addSystemMessage: status.addSystemMessage,
setStatus: status.set,
setComposerText: composer.setText,
openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId),
openThreadInCurrentPanel: thread.selectThread,
notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin),
notifyThreadRenamed: (threadId, name) => {
plugin.notifyThreadRenamed(threadId, name);
},
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
refreshThreads: thread.refreshThreads,
refreshSharedThreadListFromOpenSurface: () => {
plugin.refreshSharedThreadListFromOpenSurface();
},
};
const actions: ChatThreadActions = {
compactThread: (threadId) => compactThread(threadActionHost, threadId),
archiveThread: (threadId, saveMarkdown) => archiveThread(threadActionHost, threadId, saveMarkdown),
forkThread: (threadId) => forkThread(threadActionHost, threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(threadActionHost, threadId, turnId, archiveSource),
renameThread: (threadId, name) => renameThread(threadActionHost, threadId, name),
rollbackThread: (threadId) => rollbackThread(threadActionHost, threadId),
};
const goals = createGoalActions({
stateStore,
currentClient,
ensureConnected: client.ensureConnected,
addSystemMessage: status.addSystemMessage,
addGoalEvent: (item) => {
stateStore.dispatch({ type: "message-stream/item-upserted", item });
},
refreshLiveState: liveState.refresh,
});
let resume: ResumeController | null = null;
const restoration = new RestorationController({
deferredTasks,
opened: lifecycle.getOpened,
resumeThread: (threadId) => requireThreadController(resume, "resume controller").resumeThread(threadId),
invalidateResumeWork,
stateStore,
setStatus: status.set,
refreshTabHeader: thread.refreshTabHeader,
});
resume = new ResumeController({
stateStore,
vaultPath: plugin.vaultPath,
resumeWork,
history,
restoration,
currentClient,
ensureConnected: client.ensureConnected,
closing: lifecycle.getClosing,
resetThreadTurnPresence,
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
addSystemMessage: status.addSystemMessage,
refreshLiveState: liveState.refresh,
syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId),
recoverTokenUsageFromRollout: (path) =>
recoverRolloutTokenUsage(path, async (filePath, options) => {
const response = await currentClient()?.readFile(filePath, options);
return response?.dataBase64 ?? "";
}),
});
const identity = createIdentitySync({
stateStore,
restoration,
invalidateResumeWork,
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
refreshTabHeader: thread.refreshTabHeader,
refreshLiveState: liveState.refresh,
});
return {
history,
actions,
goals,
restoration,
resume: requireThreadController(resume, "resume controller"),
identity,
rename,
autoTitle,
invalidateResumeWork,
};
}
function requireThreadController<T>(controller: T | null, name: string): T {
if (!controller) throw new Error(`Chat thread controller graph did not initialize ${name}.`);
return controller;
}
interface ThreadSelectionActionsContext {
plugin: CodexChatHost;
state: {
stateStore: ChatStateStore;
};
status: {
addSystemMessage: (text: string) => void;
};
thread: {
resumeThread: (threadId: string) => Promise<void>;
};
}
export function createThreadSelectionActions(
context: ThreadSelectionActionsContext,
refs: {
closeForThreadSelection: () => void;
},
) {
const { plugin, thread, status } = context;
const stateStore = context.state.stateStore;
const selection = createSelectionActions({
stateStore,
closeForThreadSelection: () => {
refs.closeForThreadSelection();
},
focusThreadInOpenView: (threadId) => plugin.focusThreadInOpenView(threadId),
resumeThread: thread.resumeThread,
addSystemMessage: status.addSystemMessage,
});
return { selection };
}

View file

@ -5,14 +5,17 @@ import type { ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/sur
import { ChatPanelToolbar } from "../panel/surface/toolbar";
import { ChatPanelGoal } from "../panel/surface/goal";
import { ChatPanelMessageStream, type ChatPanelMessageStreamPresenter } from "../panel/surface/message-stream";
import { ChatPanelComposer, type ChatPanelComposerController } from "../panel/surface/composer";
import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerController } from "../panel/surface/composer";
import { ChatPanelShellStateContext, createChatPanelShellState, syncChatPanelShellState, type ChatPanelShellState } from "./shell-state";
export interface ChatPanelShellParts {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;
messageStream: ChatPanelMessageStreamPresenter;
composer: ChatPanelComposerController;
composer: {
controller: ChatPanelComposerController;
actions: ChatPanelComposerActions;
};
}
export interface ChatPanelShellProps {
@ -111,7 +114,7 @@ function ChatPanelShell({ showToolbar, parts, shellState }: ChatPanelShellProps
</div>
<ChatPanelMessageStream presenter={parts.messageStream} />
<div className="codex-panel__region codex-panel__region--composer" data-codex-panel-shell-region="composer">
<ChatPanelComposer controller={parts.composer} />
<ChatPanelComposer controller={parts.composer.controller} actions={parts.composer.actions} />
</div>
</div>
</ChatPanelShellStateContext.Provider>

View file

@ -69,8 +69,7 @@ export class CodexThreadsView extends ItemView {
) {
super(leaf);
this.deferredTasks = createThreadsViewDeferredTasks(() => this.containerEl.win);
this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath);
this.connection.setHandlers({
this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath, {
onNotification: () => {
this.scheduleRefresh();
},

View file

@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppServerClient } from "../../src/app-server/connection/client";
import { ConnectionManager, StaleConnectionError } from "../../src/app-server/connection/connection-manager";
import {
ConnectionManager,
type ConnectionManagerHandlers,
StaleConnectionError,
} from "../../src/app-server/connection/connection-manager";
import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport";
import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages";
@ -50,18 +54,13 @@ describe("ConnectionManager", () => {
const manager = new ConnectionManager(
() => "/bin/codex",
"/vault",
silentConnectionHandlers(),
(codexPath, cwd, handlers) =>
new AppServerClient(codexPath, cwd, handlers, 5, (transportHandlers) => {
transport = new SilentTransport(transportHandlers);
return transport;
}),
);
manager.setHandlers({
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit: () => undefined,
});
await expect(manager.connect()).rejects.toThrow("Codex app-server request timed out: initialize");
@ -74,18 +73,13 @@ describe("ConnectionManager", () => {
const manager = new ConnectionManager(
() => "/bin/codex",
"/vault",
silentConnectionHandlers(),
(codexPath, cwd, handlers) =>
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
transport = new SilentTransport(transportHandlers);
return transport;
}),
);
manager.setHandlers({
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit: () => undefined,
});
const first = manager.connect();
const second = manager.connect();
@ -103,18 +97,13 @@ describe("ConnectionManager", () => {
const manager = new ConnectionManager(
() => "/bin/codex",
"/vault",
{ ...silentConnectionHandlers(), onExit },
(codexPath, cwd, handlers) =>
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
transport = new SilentTransport(transportHandlers);
return transport;
}),
);
manager.setHandlers({
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
});
const connecting = manager.connect();
transport.emitExit();
@ -124,78 +113,19 @@ describe("ConnectionManager", () => {
expect(manager.currentClient()).toBeNull();
});
it("uses the latest attached handlers for app-server events", async () => {
let transport!: SilentTransport;
const firstExit = vi.fn();
const secondExit = vi.fn();
const manager = new ConnectionManager(
() => "/bin/codex",
"/vault",
(codexPath, cwd, handlers) =>
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
transport = new SilentTransport(transportHandlers);
return transport;
}),
);
manager.setHandlers({
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit: firstExit,
});
manager.setHandlers({
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit: secondExit,
});
const connecting = manager.connect();
transport.emitExit();
await expect(connecting).rejects.toThrow("Codex app-server exited: unknown");
expect(firstExit).not.toHaveBeenCalled();
expect(secondExit).toHaveBeenCalledOnce();
});
it("fails clearly when app-server events arrive before handlers are attached", async () => {
let transport!: SilentTransport;
const manager = new ConnectionManager(
() => "/bin/codex",
"/vault",
(codexPath, cwd, handlers) =>
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
transport = new SilentTransport(transportHandlers);
return transport;
}),
);
const connecting = manager.connect();
expect(() => {
transport.emitExit();
}).toThrow("ConnectionManager handlers have not been attached.");
await expect(connecting).rejects.toThrow("Codex app-server exited: unknown");
});
it("marks initialization completed after disconnect as stale", async () => {
let transport!: SilentTransport;
const onExit = vi.fn();
const manager = new ConnectionManager(
() => "/bin/codex",
"/vault",
{ ...silentConnectionHandlers(), onExit },
(codexPath, cwd, handlers) =>
new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => {
transport = new SilentTransport(transportHandlers);
return transport;
}),
);
manager.setHandlers({
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
});
const connecting = manager.connect();
manager.disconnect();
@ -206,3 +136,12 @@ describe("ConnectionManager", () => {
expect(manager.currentClient()).toBeNull();
});
});
function silentConnectionHandlers(): ConnectionManagerHandlers {
return {
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit: () => undefined,
};
}

View file

@ -4,7 +4,7 @@ import type { App } from "obsidian";
import { describe, expect, it, vi } from "vitest";
import { h } from "preact";
import { ChatComposerController } from "../../../../../src/features/chat/conversation/composer/controller";
import { ChatComposerController, type ChatComposerRenderActions } from "../../../../../src/features/chat/conversation/composer/controller";
import { ComposerShell } from "../../../../../src/features/chat/ui/composer";
import { createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/state/reducer";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
@ -13,8 +13,13 @@ import { installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
function renderComposerController(parent: HTMLElement, controller: ChatComposerController, stateStore: ChatStateStore): void {
renderUiRoot(parent, h(ComposerShell, controller.renderState(stateStore.getState())));
function renderComposerController(
parent: HTMLElement,
controller: ChatComposerController,
stateStore: ChatStateStore,
actions: ChatComposerRenderActions = { submit: vi.fn() },
): void {
renderUiRoot(parent, h(ComposerShell, controller.renderState(stateStore.getState(), actions)));
}
describe("ChatComposerController", () => {
@ -32,6 +37,7 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
@ -87,6 +93,7 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
@ -149,6 +156,7 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
@ -204,6 +212,7 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
@ -241,7 +250,7 @@ describe("ChatComposerController", () => {
expect(parent.querySelector<HTMLElement>(".codex-panel__composer-meta-icon")?.classList.contains("is-active")).toBe(false);
});
it("delegates submit events through attached action handlers", () => {
it("delegates submit events through render actions", () => {
const stateStore = createChatStateStore();
stateStore.dispatch({ type: "composer/draft-set", draft: "hello" });
const parent = document.createElement("div");
@ -252,6 +261,7 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({
@ -279,12 +289,8 @@ describe("ChatComposerController", () => {
onDraftChange: vi.fn(),
onHeightChange: vi.fn(),
});
controller.setActionHandlers({
submit,
threadScrollFromComposer: vi.fn(),
});
renderComposerController(parent, controller, stateStore);
renderComposerController(parent, controller, stateStore, { submit });
composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
expect(submit).toHaveBeenCalledOnce();
@ -300,6 +306,7 @@ describe("ChatComposerController", () => {
viewId: "view",
sendShortcut: () => "enter",
scrollThreadFromComposerEdges: () => false,
threadScrollFromComposer: vi.fn(),
canInterrupt: (_state) => false,
composerPlaceholder: (_state) => "Ask Codex to work on this task...",
composerMeta: (_state) => ({

View file

@ -330,7 +330,7 @@ function shellProps(store: ReturnType<typeof createChatStateStore>) {
interface TestShellParts {
parts: ChatPanelShellParts;
composerRenderState: ReturnType<
typeof vi.fn<(state: ChatPanelComposerShellState) => ReturnType<ChatPanelShellParts["composer"]["renderState"]>>
typeof vi.fn<(state: ChatPanelComposerShellState) => ReturnType<ChatPanelShellParts["composer"]["controller"]["renderState"]>>
>;
messageStreamRenderState: ReturnType<
typeof vi.fn<(state: ChatPanelMessageStreamShellState) => ReturnType<ChatPanelShellParts["messageStream"]["renderState"]>>
@ -401,7 +401,12 @@ function trackedShellParts(): TestShellParts {
renderState: messageStreamRenderState,
},
composer: {
renderState: composerRenderState,
controller: {
renderState: composerRenderState,
},
actions: {
submit: vi.fn(),
},
},
},
composerRenderState,

View file

@ -98,45 +98,50 @@ function shellParts(store: ReturnType<typeof createChatStateStore>, toolbarActio
}),
},
composer: {
renderState: () => ({
viewId: "view",
draft: "",
busy: false,
canInterrupt: false,
normalPlaceholder: "Ask Codex to work on this task...",
suggestions: [],
selectedSuggestionIndex: 0,
callbacks: {
onInput: vi.fn(),
onUpdateSuggestions: vi.fn(),
onKeydown: vi.fn(),
onSendOrInterrupt: vi.fn(),
onHeightChange: vi.fn(),
onSuggestionHover: vi.fn(),
onSuggestionInsert: vi.fn(),
},
meta: {
fatal: null,
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
controller: {
renderState: () => ({
viewId: "view",
draft: "",
busy: false,
canInterrupt: false,
normalPlaceholder: "Ask Codex to work on this task...",
suggestions: [],
selectedSuggestionIndex: 0,
callbacks: {
onInput: vi.fn(),
onUpdateSuggestions: vi.fn(),
onKeydown: vi.fn(),
onSendOrInterrupt: vi.fn(),
onHeightChange: vi.fn(),
onSuggestionHover: vi.fn(),
onSuggestionInsert: vi.fn(),
},
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
model: "default",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
modelChoices: [],
effortChoices: [],
},
onComposer: () => undefined,
}),
meta: {
fatal: null,
context: {
cells: [
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
{ text: "⣀", placeholder: true },
],
percent: "--%",
},
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default",
model: "default",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
modelChoices: [],
effortChoices: [],
},
onComposer: () => undefined,
}),
},
actions: {
submit: vi.fn(),
},
},
};
}

View file

@ -39,15 +39,16 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => {
class StaleConnectionError extends Error {}
class ConnectionManager {
private handlers: { onNotification: (notification: ServerNotification) => void; onExit: () => void } | null = null;
setHandlers(handlers: {
onNotification: (notification: ServerNotification) => void;
onServerRequest: (request: unknown) => void;
onLog: (message: string) => void;
onExit: () => void;
}): void {
this.handlers = handlers;
constructor(
_codexPath: () => string,
_cwd: string,
private readonly handlers: {
onNotification: (notification: ServerNotification) => void;
onServerRequest: (request: unknown) => void;
onLog: (message: string) => void;
onExit: () => void;
},
) {
connectionMock.state.onNotification = handlers.onNotification;
connectionMock.state.onExit = handlers.onExit;
}
@ -81,7 +82,7 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => {
exit(): void {
connectionMock.state.connected = false;
this.handlers?.onExit();
this.handlers.onExit();
}
}

View file

@ -35,7 +35,7 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => {
class StaleConnectionError extends Error {}
class ConnectionManager {
setHandlers(handlers: { onExit: () => void }): void {
constructor(_codexPath: () => string, _cwd: string, handlers: { onExit: () => void }) {
connectionMock.state.onExit = handlers.onExit;
}