Make chat controller composition explicit

This commit is contained in:
murashit 2026-06-10 12:51:46 +09:00
parent 92d98ed767
commit 36d039d977
15 changed files with 837 additions and 230 deletions

View file

@ -19,9 +19,10 @@ import type { RestoredThreadController } from "../threads/restored-thread-contro
import type { ThreadIdentityActions } from "../threads/thread-identity-actions";
import type { ThreadResumeController } from "../threads/thread-resume-controller";
import type { ThreadSelectionActions } from "../threads/thread-selection-controller";
import type { ChatViewRenderController } from "./view-render-controller";
import type { ChatViewRenderController, ChatViewSlotRenderers } from "./view-render-controller";
import type { ChatMessageRenderer } from "../ui/message-stream";
import type { ChatControllerCompositionPorts } from "./controller-ports";
import { createChatControllerCompositionActions, requireCompositionRef, type ChatControllerCompositionRefs } from "./controller-wiring";
import {
createChatServerActionControllers,
createChatConnectionControllers,
@ -73,6 +74,7 @@ export interface ChatViewControllers {
render: {
controller: ChatViewRenderController;
messages: ChatMessageRenderer;
attachSlotRenderers: (slotRenderers: ChatViewSlotRenderers) => void;
openView: () => void;
closeView: () => void;
applyViewState: (state: unknown) => void;
@ -82,82 +84,191 @@ export interface ChatViewControllers {
export function createChatViewControllers(ports: ChatControllerCompositionPorts): ChatViewControllers {
const connection = new ConnectionManager(() => ports.plugin.settings.codexPath, ports.plugin.vaultPath);
const { renderController } = createViewRenderControllerGroup(ports, { connection });
const refs: ChatControllerCompositionRefs = {
renderController,
controller: null,
connectionController: null,
threadSelection: null,
threadRename: null,
threadResume: null,
restoredThread: null,
serverMetadata: null,
serverDiagnostics: null,
messageRenderer: null,
composerController: null,
};
const actions = createChatControllerCompositionActions(ports, refs);
const runtimeSettings = createChatRuntimeSettingsActions({
stateStore: ports.state.stateStore,
currentClient: ports.client.getClient,
runtimeSnapshot: ports.runtime.runtimeSnapshot,
collaborationModeLabel: ports.runtime.collaborationModeLabel,
addSystemMessage: ports.status.addSystemMessage,
addSystemMessage: actions.status.addSystemMessage,
});
const { history, threadActions, goals, restoredThread, threadResume, threadIdentity, threadRename } = createThreadControllerGroup(ports, {
connection,
});
const { toolbarPanels, applyViewState } = createPanelUiControllerGroup(ports, {
threadActions,
});
const { threadSelection } = createThreadSelectionControllerGroup(ports, {
toolbarPanels,
});
const { reconnectActions } = createChatReconnectControllerGroup(ports, {
connection,
});
const { serverThreads, serverMetadata, serverDiagnostics } = createChatServerActionControllers(ports, {
const threadControllers = createThreadControllerGroup(
{
...ports,
client: actions.client,
render: actions.render,
status: actions.status,
thread: actions.thread,
scroll: actions.scroll,
composer: actions.composer,
},
{
connection,
},
);
const { history, threadActions, goals, threadIdentity } = threadControllers;
refs.restoredThread = threadControllers.restoredThread;
refs.threadResume = threadControllers.threadResume;
refs.threadRename = threadControllers.threadRename;
const threadRename = requireCompositionRef(refs.threadRename, "thread rename controller");
const lifecycleActions = {
...ports.lifecycle,
invalidateResumeWork: threadControllers.invalidateResumeWork,
};
const { toolbarPanels, applyViewState } = createPanelUiControllerGroup(
{
...ports,
lifecycle: lifecycleActions,
render: actions.render,
thread: actions.thread,
},
{
threadActions,
},
);
refs.threadSelection = createThreadSelectionControllerGroup(
{
...ports,
thread: actions.thread,
status: actions.status,
},
{
toolbarPanels,
},
).threadSelection;
const { reconnectActions } = createChatReconnectControllerGroup(
{
...ports,
client: actions.client,
lifecycle: lifecycleActions,
render: actions.render,
status: actions.status,
thread: actions.thread,
},
{
connection,
},
);
const serverActionControllers = createChatServerActionControllers(ports, {
connection,
goals,
});
const { serverThreads, serverMetadata, serverDiagnostics } = serverActionControllers;
refs.serverMetadata = serverMetadata;
refs.serverDiagnostics = serverDiagnostics;
const serverRequestHost = {
currentClient: ports.client.getClient,
};
const controller = createChatInboundController(ports, {
serverMetadata,
serverDiagnostics,
threadRename,
respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result),
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
});
const { connectionController } = createChatConnectionControllers(ports, {
connection,
serverMetadata,
serverDiagnostics,
});
refs.controller = createChatInboundController(
{
...ports,
render: actions.render,
thread: actions.thread,
},
{
serverMetadata,
serverDiagnostics,
threadRename,
respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result),
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
},
);
refs.connectionController = createChatConnectionControllers(
{
...ports,
client: actions.client,
lifecycle: lifecycleActions,
thread: actions.thread,
status: actions.status,
liveState: ports.liveState,
render: actions.render,
},
{
connection,
serverMetadata,
serverDiagnostics,
},
).connectionController;
const inboundController = requireCompositionRef(refs.controller, "inbound controller");
const connectionController = requireCompositionRef(refs.connectionController, "connection controller");
connection.setHandlers({
onNotification: (notification) => {
controller.handleNotification(notification);
inboundController.handleNotification(notification);
ports.liveState.refresh();
ports.render.schedule();
actions.render.schedule();
},
onServerRequest: (request) => {
controller.handleServerRequest(request);
inboundController.handleServerRequest(request);
ports.liveState.refresh();
ports.render.now();
actions.render.now();
},
onLog: (message) => {
controller.handleAppServerLog(message);
ports.render.now();
inboundController.handleAppServerLog(message);
actions.render.now();
},
onExit: () => {
connectionController.handleExit();
},
});
const { pendingRequests, messageRenderer, composerController, composerSubmission } = createConversationSurfaceControllerGroup(ports, {
controller,
serverThreads,
runtimeSettings,
threadActions,
threadRename,
reconnectActions,
goals,
history,
});
const { scheduleAppServerWarmup, openView, closeView } = createConnectionLifecycleControllerGroup(ports, {
connection,
composerController,
messageRenderer,
serverThreads,
serverMetadata,
});
const conversationControllers = createConversationSurfaceControllerGroup(
{
...ports,
client: actions.client,
render: actions.render,
runtime: actions.runtime,
thread: actions.thread,
status: actions.status,
scroll: actions.scroll,
},
{
controller: inboundController,
serverThreads,
runtimeSettings,
threadActions,
threadRename,
reconnectActions,
goals,
history,
},
);
const { pendingRequests, composerSubmission } = conversationControllers;
refs.messageRenderer = conversationControllers.messageRenderer;
refs.composerController = conversationControllers.composerController;
const messageRenderer = requireCompositionRef(refs.messageRenderer, "message renderer");
const composerController = requireCompositionRef(refs.composerController, "composer controller");
const { scheduleAppServerWarmup, openView, closeView } = createConnectionLifecycleControllerGroup(
{
...ports,
client: actions.client,
lifecycle: lifecycleActions,
render: actions.render,
},
{
connection,
composerController,
messageRenderer,
serverThreads,
serverMetadata,
},
);
const threadResume = requireCompositionRef(refs.threadResume, "thread resume controller");
const restoredThread = requireCompositionRef(refs.restoredThread, "restored thread controller");
const threadSelection = requireCompositionRef(refs.threadSelection, "thread selection controller");
return {
connection: {
@ -167,7 +278,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
scheduleWarmup: scheduleAppServerWarmup,
},
inbound: {
controller,
controller: inboundController,
},
serverActions: {
threads: serverThreads,
@ -200,6 +311,9 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
render: {
controller: renderController,
messages: messageRenderer,
attachSlotRenderers: (slotRenderers) => {
renderController.setSlotRenderers(slotRenderers);
},
openView,
closeView,
applyViewState,

View file

@ -7,13 +7,7 @@ import type { ChatState, ChatStateStore } from "../chat-state";
import type { CodexChatHost } from "../chat-host";
import type { ChatMessageScrollIntentController } from "../panel/message-scroll-intent-controller";
import type { DisplayDetailSection, DisplayItem } from "../display/types";
import type {
ChatConnectionWorkTracker,
ChatResumeWorkTracker,
ChatViewDeferredTasks,
ChatViewRenderScheduleOptions,
RestoredThreadState,
} from "./lifecycle";
import type { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks, ChatViewRenderScheduleOptions } from "./lifecycle";
import type { ComposerMetaViewModel } from "./model";
export interface ChatControllerCompositionPorts {
@ -28,7 +22,6 @@ export interface ChatControllerCompositionPorts {
liveState: ChatPanelLiveStateContext;
scroll: ChatPanelScrollContext;
status: ChatPanelStatusContext;
composer: ChatPanelComposerContext;
}
interface ChatPanelObsidianContext {
@ -52,7 +45,6 @@ interface ChatPanelClientContext {
getClient: () => AppServerClient | null;
setClient: (client: AppServerClient | null) => void;
clear: () => void;
ensureConnected: () => Promise<void>;
}
interface ChatPanelLifecycleContext {
@ -65,7 +57,6 @@ interface ChatPanelLifecycleContext {
getClosing: () => boolean;
setClosing: (closing: boolean) => void;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
scheduleDeferredDiagnostics: () => void;
clearDeferredDiagnostics: () => void;
scheduleDeferredRestoredThreadHydration: () => void;
@ -75,17 +66,11 @@ interface ChatPanelLifecycleContext {
interface ChatPanelRenderContext {
panelRoot: () => HTMLElement | null;
renderToolbar: (toolbar: HTMLElement) => void;
renderGoal: (goal: HTMLElement) => void;
renderMessages: (parent: HTMLElement) => void;
renderComposer: (parent: HTMLElement) => void;
pendingRequestsSignature: () => string;
activeComposerThreadName: () => string | null;
composerPlaceholder: () => string;
composerMetaViewModel: () => ComposerMetaViewModel;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
now: () => void;
shellSlots: () => void;
schedule: (options?: ChatViewRenderScheduleOptions) => void;
}
@ -93,7 +78,6 @@ interface ChatPanelRuntimeContext {
runtimeSnapshot: () => RuntimeSnapshot;
collaborationModeLabel: () => string;
connectionDiagnosticDetails: () => DisplayDetailSection[];
mcpStatusLines: () => Promise<string[]>;
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];
@ -102,16 +86,8 @@ interface ChatPanelRuntimeContext {
interface ChatThreadContext {
ensureRestoredThreadLoaded: () => Promise<boolean>;
startNewThread: () => Promise<void>;
selectThread: (threadId: string) => Promise<void>;
resumeThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
publishAppServerMetadataSnapshot: () => void;
loadSharedThreadList: () => Promise<void>;
notifyIdentityChanged: () => void;
resetTurnPresence: (hadTurns: boolean) => void;
restorePlaceholder: (restoredThread: RestoredThreadState) => void;
clearRestoredLifecycle: () => void;
refreshTabHeader: () => void;
}
@ -127,10 +103,4 @@ interface ChatPanelScrollContext {
interface ChatPanelStatusContext {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
}
interface ChatPanelComposerContext {
setText: (text: string) => void;
}

View file

@ -0,0 +1,143 @@
import type { ChatServerDiagnosticsActions } from "../server-actions/diagnostics-actions";
import type { ChatServerMetadataActions } from "../server-actions/metadata-actions";
import type { ChatComposerController } from "../composer/controller";
import type { ChatInboundController } from "../inbound/controller";
import type { RestoredThreadController } from "../threads/restored-thread-controller";
import type { ChatConnectionController } from "../session/connection-controller";
import type { ThreadRenameController } from "../threads/thread-rename-controller";
import type { ThreadResumeController } from "../threads/thread-resume-controller";
import type { ThreadSelectionActions } from "../threads/thread-selection-controller";
import type { ChatMessageRenderer } from "../ui/message-stream";
import type { ChatControllerCompositionPorts } from "./controller-ports";
import type { ChatViewRenderController } from "./view-render-controller";
export interface ChatControllerCompositionRefs {
renderController: ChatViewRenderController;
controller: ChatInboundController | null;
connectionController: ChatConnectionController | null;
threadSelection: ThreadSelectionActions | null;
threadRename: ThreadRenameController | null;
threadResume: ThreadResumeController | null;
restoredThread: RestoredThreadController | null;
serverMetadata: ChatServerMetadataActions | null;
serverDiagnostics: ChatServerDiagnosticsActions | null;
messageRenderer: ChatMessageRenderer | null;
composerController: ChatComposerController | null;
}
export interface ChatControllerCompositionActions {
client: ChatControllerCompositionPorts["client"] & {
ensureConnected: () => Promise<void>;
};
render: ChatControllerCompositionPorts["render"] & {
now: () => void;
shellSlots: () => void;
};
status: ChatControllerCompositionPorts["status"] & {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: Parameters<ChatInboundController["addStructuredSystemMessage"]>[1]) => void;
};
scroll: ChatControllerCompositionPorts["scroll"];
thread: ChatControllerCompositionPorts["thread"] & {
selectThread: (threadId: string) => Promise<void>;
resumeThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
publishAppServerMetadataSnapshot: () => void;
resetTurnPresence: (hadTurns: boolean) => void;
restorePlaceholder: (restoredThreadState: Parameters<RestoredThreadController["restore"]>[0]) => void;
clearRestoredLifecycle: () => void;
};
runtime: ChatControllerCompositionPorts["runtime"] & {
mcpStatusLines: () => Promise<string[]>;
};
composer: {
setText: (text: string) => void;
};
}
export function createChatControllerCompositionActions(
ports: ChatControllerCompositionPorts,
refs: ChatControllerCompositionRefs,
): ChatControllerCompositionActions {
const render = {
...ports.render,
now: () => {
refs.renderController.render();
},
shellSlots: () => {
refs.renderController.renderShellSlots();
},
};
const status = {
...ports.status,
addSystemMessage: (text: string) => {
requireCompositionRef(refs.controller, "inbound controller").addSystemMessage(text);
render.now();
},
addStructuredSystemMessage: (text: string, details: Parameters<ChatInboundController["addStructuredSystemMessage"]>[1]) => {
requireCompositionRef(refs.controller, "inbound controller").addStructuredSystemMessage(text, details);
render.now();
},
};
const threadNavigation = {
selectThread: (threadId: string) => requireCompositionRef(refs.threadSelection, "thread selection controller").selectThread(threadId),
resumeThread: (threadId: string) => requireCompositionRef(refs.threadResume, "thread resume controller").resumeThread(threadId),
};
const threadRefresh = {
refreshThreads: () => requireCompositionRef(refs.connectionController, "connection controller").refreshThreads(),
refreshSkills: (forceReload?: boolean) =>
requireCompositionRef(refs.connectionController, "connection controller").refreshSkills(forceReload),
publishAppServerMetadataSnapshot: () => {
requireCompositionRef(refs.serverMetadata, "server metadata actions").publishAppServerMetadataSnapshot();
},
};
const threadLifecycle = {
resetTurnPresence: (hadTurns: boolean) => {
requireCompositionRef(refs.threadRename, "thread rename controller").resetThreadTurnPresence(hadTurns);
},
restorePlaceholder: (restoredThreadState: Parameters<RestoredThreadController["restore"]>[0]) => {
requireCompositionRef(refs.restoredThread, "restored thread controller").restore(restoredThreadState);
},
clearRestoredLifecycle: () => {
requireCompositionRef(refs.restoredThread, "restored thread controller").clear();
},
};
return {
client: {
...ports.client,
ensureConnected: () => requireCompositionRef(refs.connectionController, "connection controller").ensureConnected(),
},
render,
status,
scroll: {
...ports.scroll,
forceBottom: () => {
ports.scroll.forceBottom();
requireCompositionRef(refs.messageRenderer, "message renderer").forceMessagesToBottom();
},
},
thread: {
...ports.thread,
...threadNavigation,
...threadRefresh,
...threadLifecycle,
},
runtime: {
...ports.runtime,
mcpStatusLines: () => requireCompositionRef(refs.serverDiagnostics, "server diagnostics actions").mcpStatusLines(),
},
composer: {
setText: (text) => {
requireCompositionRef(refs.composerController, "composer controller").setDraft(text, { focus: true, renderIfDetached: true });
},
},
};
}
export function requireCompositionRef<T>(value: T | null, name: string): T {
if (!value) throw new Error(`Chat controller composition did not initialize ${name}.`);
return value;
}

View file

@ -124,18 +124,14 @@ export class ChatConnectionWorkTracker {
export class ChatResumeWorkTracker {
private state: ChatResumeLifecycleState = { kind: "idle" };
constructor(private readonly onInvalidate: () => void) {}
begin(threadId: string): ActiveChatResume {
const resume: ActiveChatResume = { kind: "resuming", threadId };
this.state = transitionChatResumeLifecycle(this.state, { type: "started", resume });
this.onInvalidate();
return resume;
}
invalidate(): void {
this.state = transitionChatResumeLifecycle(this.state, { type: "invalidated" });
this.onInvalidate();
}
isStale(resume: ActiveChatResume): boolean {

View file

@ -1,4 +1,6 @@
import type { ConnectionManager } from "../../../app-server/connection-manager";
import type { ChatStateStore } from "../chat-state";
import type { CodexChatHost } from "../chat-host";
import type { ChatServerMetadataActions } from "../server-actions/metadata-actions";
import type { ChatServerThreadActions } from "../server-actions/thread-actions";
import type { ChatComposerController } from "../composer/controller";
@ -9,11 +11,24 @@ import { ToolbarPanelController } from "./toolbar-controller";
import { ChatViewRenderController } from "./view-render-controller";
import { applyChatViewState } from "./view-state-controller";
import type { ChatMessageRenderer } from "../ui/message-stream";
import { applyCachedSharedAppServerState } from "./cached-app-server-state";
import type { ChatControllerCompositionPorts } from "./controller-ports";
import { applyCachedSharedAppServerState, type CachedSharedAppServerStateSource } from "./cached-app-server-state";
import type { ChatViewDeferredTasks, ChatViewRenderScheduleOptions, RestoredThreadState } from "./lifecycle";
import { createChatShellRenderPort } from "./shell-render";
type ViewRenderControllerGroupPorts = Pick<ChatControllerCompositionPorts, "lifecycle" | "plugin" | "render" | "state">;
interface ViewRenderControllerGroupPorts {
plugin: Pick<CodexChatHost, "settings">;
state: {
stateStore: ChatStateStore;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
};
render: {
panelRoot: () => HTMLElement | null;
pendingRequestsSignature: () => string;
activeComposerThreadName: () => string | null;
};
}
export function createViewRenderControllerGroup(
context: ViewRenderControllerGroupPorts,
@ -33,10 +48,6 @@ export function createViewRenderControllerGroup(
activeComposerThreadName: render.activeComposerThreadName,
}),
panelRoot: render.panelRoot,
renderToolbar: render.renderToolbar,
renderGoal: render.renderGoal,
renderMessages: render.renderMessages,
renderComposer: render.renderComposer,
clearScheduledRender: () => {
deferredTasks.clearRender();
},
@ -44,10 +55,34 @@ export function createViewRenderControllerGroup(
};
}
type ConnectionLifecycleControllerGroupPorts = Pick<
ChatControllerCompositionPorts,
"client" | "lifecycle" | "liveState" | "obsidian" | "plugin" | "render"
>;
interface ConnectionLifecycleControllerGroupPorts {
obsidian: Pick<ChatViewLifecycleHost, "handleActiveLeafChange" | "registerActiveLeafChange" | "registerEvent" | "registerPointerDown">;
plugin: CachedSharedAppServerStateSource;
client: {
clear: () => void;
ensureConnected: () => Promise<void>;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
getOpened: () => boolean;
setOpened: (opened: boolean) => void;
getClosing: () => boolean;
setClosing: (closing: boolean) => void;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
scheduleDeferredRestoredThreadHydration: () => void;
scheduleDeferredAppServerWarmup: () => void;
};
render: {
panelRoot: () => HTMLElement | null;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
now: () => void;
};
liveState: {
refresh: () => void;
deferRefresh: () => void;
};
}
export function createConnectionLifecycleControllerGroup(
context: ConnectionLifecycleControllerGroupPorts,
@ -120,7 +155,23 @@ export function createConnectionLifecycleControllerGroup(
};
}
type PanelUiControllerGroupPorts = Pick<ChatControllerCompositionPorts, "lifecycle" | "render" | "state" | "thread">;
interface PanelUiControllerGroupPorts {
state: {
stateStore: ChatStateStore;
};
lifecycle: {
invalidateResumeWork: () => void;
clearDeferredRestoredThreadHydration: () => void;
scheduleDeferredAppServerWarmup: () => void;
};
render: {
schedule: (options?: ChatViewRenderScheduleOptions) => void;
};
thread: {
clearRestoredLifecycle: () => void;
restorePlaceholder: (restoredThread: RestoredThreadState) => void;
};
}
export function createPanelUiControllerGroup(
context: PanelUiControllerGroupPorts,

View file

@ -4,18 +4,26 @@ import type { ChatShellRenderPort } from "./shell-render";
export interface ChatViewRenderControllerHost {
shell: ChatShellRenderPort;
panelRoot: () => HTMLElement | null;
clearScheduledRender: () => void;
}
export interface ChatViewSlotRenderers {
renderToolbar: (toolbar: HTMLElement) => void;
renderGoal: (goal: HTMLElement) => void;
renderMessages: (parent: HTMLElement) => void;
renderComposer: (parent: HTMLElement) => void;
clearScheduledRender: () => void;
}
export class ChatViewRenderController {
private shellRenderVersion = 0;
private slotRenderers: ChatViewSlotRenderers | null = null;
constructor(private readonly host: ChatViewRenderControllerHost) {}
setSlotRenderers(slotRenderers: ChatViewSlotRenderers): void {
this.slotRenderers = slotRenderers;
}
render(options: ChatViewRenderScheduleOptions = {}): void {
this.host.clearScheduledRender();
const root = this.host.panelRoot();
@ -34,18 +42,18 @@ export class ChatViewRenderController {
}
private readonly renderToolbarSlot = (toolbar: HTMLElement): void => {
this.host.renderToolbar(toolbar);
this.slotRenderers?.renderToolbar(toolbar);
};
private readonly renderGoalSlot = (goal: HTMLElement): void => {
this.host.renderGoal(goal);
this.slotRenderers?.renderGoal(goal);
};
private readonly renderMessagesSlot = (parent: HTMLElement): void => {
this.host.renderMessages(parent);
this.slotRenderers?.renderMessages(parent);
};
private readonly renderComposerSlot = (parent: HTMLElement): void => {
this.host.renderComposer(parent);
this.slotRenderers?.renderComposer(parent);
};
}

View file

@ -1,6 +1,9 @@
import { Notice } from "obsidian";
import type { ConnectionManager } from "../../../app-server/connection-manager";
import type { RuntimeSnapshot } from "../runtime/effective-settings";
import type { ChatStateStore } from "../chat-state";
import type { CodexChatHost } from "../chat-host";
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "../server-actions/diagnostics-actions";
import { createChatServerMetadataActions, type ChatServerMetadataActions } from "../server-actions/metadata-actions";
import { createChatServerThreadActions } from "../server-actions/thread-actions";
@ -10,9 +13,17 @@ import type { rejectServerRequest, respondToServerRequest } from "../requests/se
import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
import type { ThreadRenameController } from "../threads/thread-rename-controller";
import { ChatInboundController } from "../inbound/controller";
import type { ChatControllerCompositionPorts } from "../panel/controller-ports";
import type { ChatConnectionWorkTracker, ChatViewRenderScheduleOptions } from "../panel/lifecycle";
type ChatServerActionControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "runtime" | "state">;
interface ChatServerActionControllerPorts {
plugin: Pick<CodexChatHost, "applyThreadListSnapshot" | "publishAppServerMetadata" | "vaultPath">;
state: {
stateStore: ChatStateStore;
};
runtime: {
runtimeSnapshot: () => RuntimeSnapshot;
};
}
export function createChatServerActionControllers(
context: ChatServerActionControllerPorts,
@ -55,7 +66,20 @@ export function createChatServerActionControllers(
return { serverThreads, serverMetadata, serverDiagnostics };
}
type ChatInboundControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "render" | "state" | "thread">;
interface ChatInboundControllerPorts {
plugin: Pick<CodexChatHost, "notifyThreadArchived" | "notifyThreadRenamed">;
state: {
stateStore: ChatStateStore;
};
render: {
schedule: (options?: ChatViewRenderScheduleOptions) => void;
};
thread: {
refreshThreads: () => Promise<void>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
publishAppServerMetadataSnapshot: () => void;
};
}
export function createChatInboundController(
context: ChatInboundControllerPorts,
@ -92,10 +116,37 @@ export function createChatInboundController(
});
}
type ChatConnectionControllerPorts = Pick<
ChatControllerCompositionPorts,
"client" | "lifecycle" | "liveState" | "plugin" | "render" | "state" | "status" | "thread"
>;
interface ChatConnectionControllerPorts {
plugin: Pick<CodexChatHost, "settings">;
state: {
stateStore: ChatStateStore;
};
client: {
setClient: (client: ReturnType<ConnectionManager["currentClient"]>) => void;
};
lifecycle: {
connectionWork: ChatConnectionWorkTracker;
invalidateResumeWork: () => void;
scheduleDeferredDiagnostics: () => void;
clearDeferredDiagnostics: () => void;
};
thread: {
loadSharedThreadList: () => Promise<void>;
refreshTabHeader: () => void;
resetTurnPresence: (hadTurns: boolean) => void;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
liveState: {
refresh: () => void;
};
render: {
now: () => void;
schedule: (options?: ChatViewRenderScheduleOptions) => void;
};
}
export function createChatConnectionControllers(
context: ChatConnectionControllerPorts,
@ -140,10 +191,30 @@ export function createChatConnectionControllers(
};
}
type ChatReconnectControllerGroupPorts = Pick<
ChatControllerCompositionPorts,
"client" | "lifecycle" | "render" | "state" | "status" | "thread"
>;
interface ChatReconnectControllerGroupPorts {
state: {
stateStore: ChatStateStore;
};
client: {
clear: () => void;
ensureConnected: () => Promise<void>;
};
lifecycle: {
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredDiagnostics: () => void;
};
render: {
now: () => void;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
thread: {
resumeThread: (threadId: string) => Promise<void>;
};
}
export function createChatReconnectControllerGroup(
context: ChatReconnectControllerGroupPorts,

View file

@ -1,5 +1,7 @@
import type { ConnectionManager } from "../../../app-server/connection-manager";
import type { AppServerClient } from "../../../app-server/client";
import { recoverRolloutTokenUsage } from "../../../app-server/rollout-token-usage";
import type { ArchiveExportAdapter } from "../../../domain/threads/export";
import { createChatThreadActions } from "./thread-actions";
import { createChatThreadGoalActions } from "./thread-goal-actions";
import { ThreadHistoryController } from "./thread-history-controller";
@ -9,12 +11,66 @@ import { ThreadResumeController } from "./thread-resume-controller";
import { createThreadSelectionActions } from "./thread-selection-controller";
import { RestoredThreadController } from "./restored-thread-controller";
import type { ToolbarPanelController } from "../panel/toolbar-controller";
import type { ChatControllerCompositionPorts } from "../panel/controller-ports";
import type { ChatStateStore } from "../chat-state";
import type { CodexChatHost } from "../chat-host";
import type { DisplayItem } from "../display/types";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../panel/lifecycle";
type ThreadControllerGroupPorts = Pick<
ChatControllerCompositionPorts,
"client" | "composer" | "lifecycle" | "liveState" | "obsidian" | "plugin" | "render" | "scroll" | "state" | "status" | "thread"
>;
interface ThreadControllerGroupPorts {
obsidian: {
archiveAdapter: () => ArchiveExportAdapter;
};
plugin: Pick<
CodexChatHost,
| "notifyThreadArchived"
| "notifyThreadRenamed"
| "openThreadInNewView"
| "refreshSharedThreadListFromOpenSurface"
| "settings"
| "vaultPath"
>;
state: {
stateStore: ChatStateStore;
systemItem: (text: string) => DisplayItem;
};
client: {
getClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
resumeWork: ChatResumeWorkTracker;
getOpened: () => boolean;
getClosing: () => boolean;
clearDeferredRestoredThreadHydration: () => void;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
resumeThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
notifyIdentityChanged: () => void;
resetTurnPresence: (hadTurns: boolean) => void;
refreshTabHeader: () => void;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
liveState: {
refresh: () => void;
};
scroll: {
preservePosition: () => void;
forceBottom: () => void;
};
render: {
now: () => void;
shellSlots: () => void;
};
composer: {
setText: (text: string) => void;
};
}
export function createThreadControllerGroup(
context: ThreadControllerGroupPorts,
@ -35,6 +91,10 @@ export function createThreadControllerGroup(
keepCurrentScrollPosition: scroll.preservePosition,
setThreadTurnPresence: thread.resetTurnPresence,
});
const invalidateResumeWork = () => {
resumeWork.invalidate();
history.invalidate();
};
const threadActions = createChatThreadActions({
stateStore,
vaultPath: plugin.vaultPath,
@ -73,7 +133,7 @@ export function createThreadControllerGroup(
deferredTasks,
opened: lifecycle.getOpened,
resumeThread: thread.resumeThread,
invalidateResumeWork: lifecycle.invalidateResumeWork,
invalidateResumeWork,
stateStore,
systemItem: state.systemItem,
setStatus: status.set,
@ -106,7 +166,7 @@ export function createThreadControllerGroup(
const threadIdentity = createThreadIdentityActions({
stateStore,
restoredThread,
invalidateResumeWork: lifecycle.invalidateResumeWork,
invalidateResumeWork,
clearDeferredRestoredThreadHydration: lifecycle.clearDeferredRestoredThreadHydration,
resetThreadTurnPresence: thread.resetTurnPresence,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
@ -134,10 +194,22 @@ export function createThreadControllerGroup(
threadResume,
threadIdentity,
threadRename,
invalidateResumeWork,
};
}
type ThreadSelectionControllerGroupPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state" | "status" | "thread">;
interface ThreadSelectionControllerGroupPorts {
plugin: Pick<CodexChatHost, "focusThreadInOpenView">;
state: {
stateStore: ChatStateStore;
};
status: {
addSystemMessage: (text: string) => void;
};
thread: {
resumeThread: (threadId: string) => Promise<void>;
};
}
export function createThreadSelectionControllerGroup(
context: ThreadSelectionControllerGroupPorts,

View file

@ -39,6 +39,7 @@ export class ThreadResumeController {
return;
}
const resume = this.host.resumeWork.begin(threadId);
this.host.history.invalidate();
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client || this.isStale(resume)) return;

View file

@ -1,6 +1,9 @@
import type { App, Component } from "obsidian";
import type { AppServerClient } from "../../../app-server/client";
import type { ChatServerThreadActions } from "../server-actions/thread-actions";
import { ChatComposerController } from "../composer/controller";
import { activeTurnId } from "../chat-state";
import { activeTurnId, type ChatState, type ChatStateStore } from "../chat-state";
import type { ChatReconnectActions } from "../session/reconnect-actions";
import { PendingRequestController } from "../requests/pending-request-controller";
import type { ChatRuntimeSettingsActions } from "../runtime/runtime-settings-actions";
@ -13,14 +16,66 @@ import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
import type { ThreadHistoryController } from "../threads/thread-history-controller";
import type { ThreadRenameController } from "../threads/thread-rename-controller";
import type { ChatInboundController } from "../inbound/controller";
import { currentModel } from "../runtime/effective-settings";
import { currentModel, type RuntimeSnapshot } from "../runtime/effective-settings";
import { ChatMessageRenderer } from "../ui/message-stream";
import type { ChatControllerCompositionPorts } from "../panel/controller-ports";
import type { CodexChatHost } from "../chat-host";
import type { DisplayDetailSection } from "../display/types";
import type { ChatMessageScrollIntentController } from "../panel/message-scroll-intent-controller";
import type { ComposerMetaViewModel } from "../panel/model";
import type { ChatViewRenderScheduleOptions } from "../panel/lifecycle";
type ConversationSurfaceControllerGroupPorts = Pick<
ChatControllerCompositionPorts,
"client" | "lifecycle" | "liveState" | "obsidian" | "plugin" | "render" | "runtime" | "scroll" | "state" | "status" | "thread"
>;
interface ConversationSurfaceControllerGroupPorts {
obsidian: {
app: App;
owner: Component;
viewId: string;
};
plugin: Pick<CodexChatHost, "openTurnDiff" | "settings" | "vaultPath">;
state: {
stateStore: ChatStateStore;
getState: () => ChatState;
};
client: {
getClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
};
lifecycle: {
messageScrollIntent: ChatMessageScrollIntentController;
};
render: {
now: () => void;
schedule: (options?: ChatViewRenderScheduleOptions) => void;
pendingRequestsSignature: () => string;
composerPlaceholder: () => string;
composerMetaViewModel: () => ComposerMetaViewModel;
};
runtime: {
runtimeSnapshot: () => RuntimeSnapshot;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => DisplayDetailSection[];
mcpStatusLines: () => Promise<string[]>;
modelStatusLines: () => string[];
effortStatusLines: () => string[];
};
thread: {
ensureRestoredThreadLoaded: () => Promise<boolean>;
startNewThread: () => Promise<void>;
selectThread: (threadId: string) => Promise<void>;
notifyIdentityChanged: () => void;
resetTurnPresence: (hadTurns: boolean) => void;
};
liveState: {
refresh: () => void;
};
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
};
scroll: {
forceBottom: () => void;
};
}
export function createConversationSurfaceControllerGroup(
context: ConversationSurfaceControllerGroupPorts,

View file

@ -57,17 +57,27 @@ export class CodexChatView extends ItemView {
) {
super(leaf);
this.deferredTasks = new ChatViewDeferredTasks(() => this.containerEl.win);
this.resumeWork = new ChatResumeWorkTracker(() => {
this.controllers.thread.history.invalidate();
});
this.resumeWork = new ChatResumeWorkTracker();
this.messageScrollIntent = new ChatMessageScrollIntentController();
this.controllers = createChatViewControllers(this.createControllerPorts());
this.slotPorts = this.createSlotRendererPorts();
this.slotPorts = this.createSlotRendererPorts(this.controllers);
this.controllers.render.attachSlotRenderers({
renderToolbar: (toolbar) => {
renderToolbarSlot(toolbar, this.slotPorts);
},
renderGoal: (goal) => {
renderGoalSlot(goal, this.slotPorts);
},
renderMessages: (parent) => {
renderMessagesSlot(parent, this.slotPorts);
},
renderComposer: (parent) => {
renderComposerSlot(parent, this.slotPorts);
},
});
}
private createControllerPorts(): ChatControllerCompositionPorts {
// Some callbacks are late-bound to controllers assigned immediately after this object is created.
// Controller constructors must not invoke those callbacks synchronously during composition.
return {
obsidian: {
app: this.app,
@ -101,7 +111,6 @@ export class CodexChatView extends ItemView {
clear: () => {
this.client = null;
},
ensureConnected: () => this.controllers.connection.controller.ensureConnected(),
},
lifecycle: {
deferredTasks: this.deferredTasks,
@ -117,10 +126,7 @@ export class CodexChatView extends ItemView {
this.closing = closing;
},
invalidateConnectionWork: () => {
this.controllers.connection.controller.invalidate();
},
invalidateResumeWork: () => {
this.invalidateResumeWork();
this.connectionWork.invalidate();
},
scheduleDeferredDiagnostics: () => {
this.scheduleDeferredDiagnostics();
@ -140,18 +146,6 @@ export class CodexChatView extends ItemView {
},
render: {
panelRoot: () => this.panelRoot(),
renderToolbar: (toolbar) => {
renderToolbarSlot(toolbar, this.slotPorts);
},
renderGoal: (goal) => {
renderGoalSlot(goal, this.slotPorts);
},
renderMessages: (parent) => {
renderMessagesSlot(parent, this.slotPorts);
},
renderComposer: (parent) => {
renderComposerSlot(parent, this.slotPorts);
},
pendingRequestsSignature: () => pendingRequestsSignature(this.slotPorts),
activeComposerThreadName: () => activeComposerThreadName(this.slotPorts),
composerPlaceholder: () => composerPlaceholder(this.slotPorts),
@ -159,12 +153,6 @@ export class CodexChatView extends ItemView {
closeToolbarPanelOnOutsidePointer: (event) => {
this.closeToolbarPanelOnOutsidePointer(event);
},
now: () => {
this.controllers.render.controller.render();
},
shellSlots: () => {
this.controllers.render.controller.renderShellSlots();
},
schedule: (options) => {
this.scheduleRender(options);
},
@ -173,7 +161,6 @@ export class CodexChatView extends ItemView {
runtimeSnapshot: () => this.runtimeSnapshot(),
collaborationModeLabel: () => this.collaborationModeLabel(),
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
mcpStatusLines: () => this.controllers.serverActions.diagnostics.mcpStatusLines(),
modelStatusLines: () => this.modelStatusLines(),
effortStatusLines: () => this.effortStatusLines(),
statusSummaryLines: () => this.statusSummaryLines(),
@ -181,26 +168,10 @@ export class CodexChatView extends ItemView {
thread: {
ensureRestoredThreadLoaded: () => this.ensureRestoredThreadLoaded(),
startNewThread: () => this.startNewThread(),
selectThread: (threadId) => this.controllers.thread.selection.selectThread(threadId),
resumeThread: (threadId) => this.controllers.thread.resume.resumeThread(threadId),
refreshThreads: () => this.controllers.connection.controller.refreshThreads(),
refreshSkills: (forceReload) => this.controllers.connection.controller.refreshSkills(forceReload),
publishAppServerMetadataSnapshot: () => {
this.controllers.serverActions.metadata.publishAppServerMetadataSnapshot();
},
loadSharedThreadList: () => this.loadSharedThreadList(),
notifyIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
resetTurnPresence: (hadTurns) => {
this.controllers.thread.rename.resetThreadTurnPresence(hadTurns);
},
restorePlaceholder: (restoredThread) => {
this.controllers.thread.restored.restore(restoredThread);
},
clearRestoredLifecycle: () => {
this.controllers.thread.restored.clear();
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
@ -218,7 +189,6 @@ export class CodexChatView extends ItemView {
scroll: {
forceBottom: () => {
this.messageScrollIntent.forceBottom();
this.controllers.render.messages.forceMessagesToBottom();
},
preservePosition: () => {
this.messageScrollIntent.preservePosition();
@ -228,28 +198,15 @@ export class CodexChatView extends ItemView {
set: (status) => {
this.dispatch({ type: "connection/status-set", status });
},
addSystemMessage: (text) => {
this.controllers.inbound.controller.addSystemMessage(text);
this.controllers.render.controller.render();
},
addStructuredSystemMessage: (text, details) => {
this.controllers.inbound.controller.addStructuredSystemMessage(text, details);
this.controllers.render.controller.render();
},
},
composer: {
setText: (text) => {
this.controllers.composer.controller.setDraft(text, { focus: true, renderIfDetached: true });
},
},
};
}
private createSlotRendererPorts(): ChatViewSlotRendererPorts {
private createSlotRendererPorts(controllers: ChatViewControllers): ChatViewSlotRendererPorts {
return {
state: {
chat: () => this.state,
connected: () => this.controllers.connection.manager.isConnected(),
connected: () => controllers.connection.manager.isConnected(),
turnBusy: () => this.turnBusy,
},
settings: {
@ -268,45 +225,45 @@ export class CodexChatView extends ItemView {
},
actions: {
toolbar: {
archiveConfirmId: () => this.controllers.toolbar.panels.archiveConfirmId(),
renameState: (threadId) => this.controllers.thread.rename.editState(threadId),
archiveConfirmId: () => controllers.toolbar.panels.archiveConfirmId(),
renameState: (threadId) => controllers.thread.rename.editState(threadId),
startNewThread: () => this.startNewThread(),
toggleChatActions: () => {
this.controllers.toolbar.panels.toggleChatActions();
controllers.toolbar.panels.toggleChatActions();
},
compactConversation: () => this.compactConversation(),
showGoalEditor: () => {
this.setGoalEditingOpen(true, { closeToolbarPanel: true });
},
toggleHistory: () => {
this.controllers.toolbar.panels.toggleHistory();
controllers.toolbar.panels.toggleHistory();
},
toggleStatusPanel: () => {
this.controllers.toolbar.panels.toggleStatus();
controllers.toolbar.panels.toggleStatus();
},
reconnectPanel: () => this.controllers.connection.reconnect.reconnectPanel(),
refreshStatusPanel: () => this.controllers.connection.controller.refreshStatusPanel(),
selectThreadFromToolbar: (threadId) => this.controllers.thread.selection.selectThreadFromToolbar(threadId),
reconnectPanel: () => controllers.connection.reconnect.reconnectPanel(),
refreshStatusPanel: () => controllers.connection.controller.refreshStatusPanel(),
selectThreadFromToolbar: (threadId) => controllers.thread.selection.selectThreadFromToolbar(threadId),
startArchive: (threadId) => {
this.controllers.toolbar.panels.startArchive(threadId);
controllers.toolbar.panels.startArchive(threadId);
},
archiveThread: (threadId, saveMarkdown) => this.controllers.toolbar.panels.archiveThread(threadId, saveMarkdown),
archiveThread: (threadId, saveMarkdown) => controllers.toolbar.panels.archiveThread(threadId, saveMarkdown),
startRename: (threadId) => {
this.controllers.thread.rename.start(threadId);
controllers.thread.rename.start(threadId);
},
updateRenameDraft: (threadId, value) => {
this.controllers.thread.rename.updateDraft(threadId, value);
controllers.thread.rename.updateDraft(threadId, value);
},
saveRename: (threadId, value) => this.controllers.thread.rename.save(threadId, value),
saveRename: (threadId, value) => controllers.thread.rename.save(threadId, value),
cancelRename: (threadId) => {
this.controllers.thread.rename.cancel(threadId);
controllers.thread.rename.cancel(threadId);
},
autoNameDraft: (threadId) => this.controllers.thread.rename.autoNameDraft(threadId),
autoNameDraft: (threadId) => controllers.thread.rename.autoNameDraft(threadId),
},
goal: {
saveObjective: (objective, tokenBudget) => this.saveGoalObjective(objective, tokenBudget),
setStatus: (threadId, status) => this.controllers.runtime.goals.setStatus(threadId, status),
clear: (threadId) => this.controllers.runtime.goals.clear(threadId),
setStatus: (threadId, status) => controllers.runtime.goals.setStatus(threadId, status),
clear: (threadId) => controllers.runtime.goals.clear(threadId),
setEditingOpen: (open) => {
this.setGoalEditingOpen(open);
},
@ -314,10 +271,10 @@ export class CodexChatView extends ItemView {
},
slots: {
renderMessages: (parent) => {
this.controllers.render.messages.render(parent);
controllers.render.messages.render(parent);
},
renderComposer: (parent) => {
this.controllers.composer.controller.render(parent);
controllers.composer.controller.render(parent);
},
},
};
@ -523,10 +480,6 @@ export class CodexChatView extends ItemView {
await this.controllers.runtime.settings.setRequestedReasoningEffortFromUi(effort);
}
private invalidateResumeWork(): void {
this.resumeWork.invalidate();
}
private async ensureRestoredThreadLoaded(): Promise<boolean> {
return this.controllers.thread.restored.ensureLoaded();
}

View file

@ -0,0 +1,163 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import type { App, Component, EventRef, WorkspaceLeaf } from "obsidian";
import type { RuntimeSnapshot } from "../../../../src/features/chat/runtime/effective-settings";
import { createChatStateStore } from "../../../../src/features/chat/chat-state";
import { createChatViewControllers } from "../../../../src/features/chat/panel/composition";
import type { ChatControllerCompositionPorts } from "../../../../src/features/chat/panel/controller-ports";
import { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks } from "../../../../src/features/chat/panel/lifecycle";
import { ChatMessageScrollIntentController } from "../../../../src/features/chat/panel/message-scroll-intent-controller";
import type { ComposerMetaViewModel } from "../../../../src/features/chat/panel/model";
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
describe("createChatViewControllers", () => {
it("constructs the chat controller graph and exposes slot renderer attachment", () => {
const controllers = createChatViewControllers(createPorts());
expect(controllers.connection.controller).toBeTruthy();
expect(controllers.inbound.controller).toBeTruthy();
expect(controllers.thread.resume).toBeTruthy();
expect(controllers.render.messages).toBeTruthy();
expect(controllers.composer.controller).toBeTruthy();
expect(() => {
controllers.render.attachSlotRenderers({
renderToolbar: vi.fn(),
renderGoal: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
});
}).not.toThrow();
});
});
function createPorts(): ChatControllerCompositionPorts {
const stateStore = createChatStateStore();
const deferredTasks = new ChatViewDeferredTasks(() => window);
const root = document.createElement("div");
return {
obsidian: {
app: createApp(),
owner: createOwner(),
viewId: "test-view",
registerEvent: vi.fn(),
registerPointerDown: vi.fn(),
registerActiveLeafChange: vi.fn(),
handleActiveLeafChange: vi.fn((_leaf: WorkspaceLeaf | null) => undefined),
archiveAdapter: () => ({
exists: vi.fn().mockResolvedValue(false),
mkdir: vi.fn().mockResolvedValue(undefined),
write: vi.fn().mockResolvedValue(undefined),
}),
},
plugin: {
settings: DEFAULT_SETTINGS,
vaultPath: "/vault",
openThreadInNewView: vi.fn().mockResolvedValue(undefined),
focusThreadInOpenView: vi.fn().mockResolvedValue(false),
openTurnDiff: vi.fn().mockResolvedValue(undefined),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshThreadsViewLiveState: vi.fn(),
refreshSharedThreadListFromOpenSurface: vi.fn(),
applyThreadListSnapshot: vi.fn(),
refreshThreadList: vi.fn(async (fetchThreads: () => Promise<readonly []>) => fetchThreads()),
cachedThreadList: () => null,
publishAppServerMetadata: vi.fn(),
cachedAppServerMetadata: () => null,
},
state: {
stateStore,
getState: () => stateStore.getState(),
systemItem: (text) => ({ id: "system", kind: "system", role: "system", text }),
},
client: {
getClient: () => null,
setClient: vi.fn(),
clear: vi.fn(),
},
lifecycle: {
deferredTasks,
resumeWork: new ChatResumeWorkTracker(),
connectionWork: new ChatConnectionWorkTracker(),
messageScrollIntent: new ChatMessageScrollIntentController(),
getOpened: () => false,
setOpened: vi.fn(),
getClosing: () => false,
setClosing: vi.fn(),
invalidateConnectionWork: vi.fn(),
scheduleDeferredDiagnostics: vi.fn(),
clearDeferredDiagnostics: vi.fn(),
scheduleDeferredRestoredThreadHydration: vi.fn(),
clearDeferredRestoredThreadHydration: vi.fn(),
scheduleDeferredAppServerWarmup: vi.fn(),
},
render: {
panelRoot: () => root,
pendingRequestsSignature: () => "",
activeComposerThreadName: () => null,
composerPlaceholder: () => "",
composerMetaViewModel: () => composerMeta(),
closeToolbarPanelOnOutsidePointer: vi.fn(),
schedule: vi.fn(),
},
runtime: {
runtimeSnapshot: () => ({}) as RuntimeSnapshot,
collaborationModeLabel: () => "Plan",
connectionDiagnosticDetails: () => [],
modelStatusLines: () => [],
effortStatusLines: () => [],
statusSummaryLines: () => [],
},
thread: {
ensureRestoredThreadLoaded: vi.fn().mockResolvedValue(false),
startNewThread: vi.fn().mockResolvedValue(undefined),
loadSharedThreadList: vi.fn().mockResolvedValue(undefined),
notifyIdentityChanged: vi.fn(),
refreshTabHeader: vi.fn(),
},
liveState: {
refresh: vi.fn(),
deferRefresh: vi.fn(),
},
scroll: {
forceBottom: vi.fn(),
preservePosition: vi.fn(),
},
status: {
set: vi.fn(),
},
};
}
function createApp(): App {
return {
vault: {
adapter: {},
on: vi.fn(() => ({}) as EventRef),
},
workspace: {
getActiveFile: () => null,
openLinkText: vi.fn(),
},
} as unknown as App;
}
function createOwner(): Component {
return {} as Component;
}
function composerMeta(): ComposerMetaViewModel {
return {
fatal: null,
context: { cells: [], percent: "--%" },
statusSummary: "",
model: "",
effort: null,
planActive: false,
autoReviewActive: false,
fastActive: false,
};
}

View file

@ -12,14 +12,21 @@ describe("ChatViewRenderController", () => {
const messages = document.createElement("div");
const composer = document.createElement("div");
const host = renderHost(root, { toolbar, goal, messages, composer });
const slotRenderers = {
renderToolbar: vi.fn(),
renderGoal: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
};
const controller = new ChatViewRenderController(host);
controller.setSlotRenderers(slotRenderers);
controller.render();
expect(host.renderToolbar).toHaveBeenCalledWith(toolbar);
expect(host.renderGoal).toHaveBeenCalledWith(goal);
expect(host.renderMessages).toHaveBeenCalledWith(messages);
expect(host.renderComposer).toHaveBeenCalledWith(composer);
expect(slotRenderers.renderToolbar).toHaveBeenCalledWith(toolbar);
expect(slotRenderers.renderGoal).toHaveBeenCalledWith(goal);
expect(slotRenderers.renderMessages).toHaveBeenCalledWith(messages);
expect(slotRenderers.renderComposer).toHaveBeenCalledWith(composer);
});
});
@ -42,10 +49,6 @@ function renderHost(
},
},
panelRoot: () => root,
renderToolbar: vi.fn(),
renderGoal: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
clearScheduledRender: vi.fn(),
};
}

View file

@ -65,12 +65,13 @@ function createController(
const client = { resumeThread } as unknown as AppServerClient;
const loadLatest = vi.fn().mockResolvedValue(undefined);
const applyLatestPage = vi.fn();
const invalidateHistory = vi.fn();
const restoredClear = vi.fn();
const host = {
stateStore,
vaultPath: "/vault",
resumeWork: new ChatResumeWorkTracker(() => undefined),
history: { loadLatest, applyLatestPage } as unknown as ThreadHistoryController,
resumeWork: new ChatResumeWorkTracker(),
history: { loadLatest, applyLatestPage, invalidate: invalidateHistory } as unknown as ThreadHistoryController,
restoredThread: { clear: restoredClear } as unknown as RestoredThreadController,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
@ -86,7 +87,16 @@ function createController(
syncThreadGoal: vi.fn().mockResolvedValue(undefined),
...overrides,
};
return { controller: new ThreadResumeController(host), host, applyLatestPage, loadLatest, restoredClear, resumeThread, stateStore };
return {
controller: new ThreadResumeController(host),
host,
applyLatestPage,
invalidateHistory,
loadLatest,
restoredClear,
resumeThread,
stateStore,
};
}
describe("ThreadResumeController", () => {

View file

@ -70,15 +70,12 @@ describe("chat view lifecycle transitions", () => {
expect(tracker.active()).toBeNull();
});
it("tracks resume work and calls the invalidation hook", () => {
const invalidate = vi.fn();
const tracker = new ChatResumeWorkTracker(invalidate);
it("tracks resume work by identity", () => {
const tracker = new ChatResumeWorkTracker();
const resume = tracker.begin("thread");
expect(invalidate).toHaveBeenCalledOnce();
expect(tracker.isStale(resume)).toBe(false);
tracker.invalidate();
expect(invalidate).toHaveBeenCalledTimes(2);
expect(tracker.isStale(resume)).toBe(true);
});