Simplify chat panel composition wiring

This commit is contained in:
murashit 2026-06-13 00:39:33 +09:00
parent 2e41795220
commit aa5b1b6ed0
54 changed files with 1365 additions and 2103 deletions

View file

@ -1,7 +1,8 @@
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import type { ServerInitialization } from "../../domain/server/initialization";
import { AppServerClient, type AppServerClientHandlers } from "./client";
import { appServerInitializationFromResponse, type AppServerInitialization } from "../protocol/initialization";
import { appServerInitializationFromResponse } from "../protocol/initialization";
export interface ConnectionManagerHandlers {
onNotification: (notification: ServerNotification) => void;
@ -14,7 +15,7 @@ export type AppServerClientFactory = (codexPath: string, cwd: string, handlers:
type ConnectionLifecycleState =
| { kind: "idle"; generation: number }
| { kind: "connecting"; generation: number; client: AppServerClient; promise: Promise<AppServerInitialization> }
| { kind: "connecting"; generation: number; client: AppServerClient; promise: Promise<ServerInitialization> }
| { kind: "connected"; generation: number; client: AppServerClient }
| { kind: "disconnected"; generation: number };
@ -47,7 +48,7 @@ export class ConnectionManager {
return Boolean(this.currentClient());
}
async connect(): Promise<AppServerInitialization> {
async connect(): Promise<ServerInitialization> {
const currentClient = this.currentClient();
if (currentClient) {
return appServerInitializationFromResponse(currentClient.initializeResponse);

View file

@ -1,9 +1,7 @@
import type { InitializeResponse as AppServerInitializeResponse } from "../../generated/app-server/InitializeResponse";
import type { ServerInitialization } from "../../domain/server/initialization";
export type AppServerInitialization = ServerInitialization;
export function appServerInitializationFromResponse(response: AppServerInitializeResponse): AppServerInitialization {
export function appServerInitializationFromResponse(response: AppServerInitializeResponse): ServerInitialization {
return {
userAgent: response.userAgent,
codexHome: response.codexHome,

View file

@ -1,123 +0,0 @@
import type { AppServerClient } from "../../app-server/connection/client";
import type { DisplayDetailSection, DisplayItem } from "./display/types";
import type { ChatStateStore } from "./state/reducer";
import type { ChatViewRenderController } from "./panel/view-render-controller";
interface ChatControllerCompositionActionPorts {
client: {
getClient: () => AppServerClient | null;
setClient: (client: AppServerClient | null) => void;
clear: () => void;
};
render: {
panelRoot: () => HTMLElement | null;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
schedule: () => void;
};
state: {
stateStore: ChatStateStore;
systemItem: (text: string) => DisplayItem;
structuredSystemItem: (text: string, details: DisplayDetailSection[]) => DisplayItem;
};
status: {
set: (status: string) => void;
};
scroll: {
forceBottom: () => void;
followBottom: () => void;
preservePosition: () => void;
};
thread: {
ensureRestoredThreadLoaded: () => Promise<boolean>;
startNewThread: () => Promise<void>;
loadSharedThreadList: () => Promise<void>;
notifyIdentityChanged: () => void;
refreshTabHeader: () => void;
};
}
export interface ChatControllerCompositionActions {
client: ChatControllerCompositionActionPorts["client"] & {
ensureConnected: () => Promise<void>;
};
render: ChatControllerCompositionActionPorts["render"] & {
now: () => void;
};
status: ChatControllerCompositionActionPorts["status"] & {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
};
scroll: ChatControllerCompositionActionPorts["scroll"];
thread: ChatControllerCompositionActionPorts["thread"] & {
selectThread: (threadId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
};
composer: {
setText: (text: string) => void;
};
}
export function createChatControllerCompositionActions(
ports: ChatControllerCompositionActionPorts,
deps: {
renderController: ChatViewRenderController;
ensureConnected: () => Promise<void>;
refreshThreads: () => Promise<void>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
selectThread: (threadId: string) => Promise<void>;
setComposerText: (text: string) => void;
},
): ChatControllerCompositionActions {
const { renderController } = deps;
const render = {
panelRoot: ports.render.panelRoot,
closeToolbarPanelOnOutsidePointer: ports.render.closeToolbarPanelOnOutsidePointer,
schedule: ports.render.schedule,
now: () => {
renderController.render();
},
};
const status = {
set: ports.status.set,
addSystemMessage: (text: string) => {
ports.state.stateStore.dispatch({ type: "message-stream/system-item-added", item: ports.state.systemItem(text) });
render.now();
},
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => {
ports.state.stateStore.dispatch({
type: "message-stream/system-item-added",
item: ports.state.structuredSystemItem(text, details),
});
render.now();
},
};
return {
client: {
getClient: ports.client.getClient,
setClient: ports.client.setClient,
clear: ports.client.clear,
ensureConnected: deps.ensureConnected,
},
render,
status,
scroll: ports.scroll,
thread: {
ensureRestoredThreadLoaded: ports.thread.ensureRestoredThreadLoaded,
startNewThread: ports.thread.startNewThread,
loadSharedThreadList: ports.thread.loadSharedThreadList,
notifyIdentityChanged: ports.thread.notifyIdentityChanged,
refreshTabHeader: ports.thread.refreshTabHeader,
selectThread: deps.selectThread,
refreshThreads: deps.refreshThreads,
refreshSkills: deps.refreshSkills,
},
composer: {
setText: (text) => {
deps.setComposerText(text);
render.now();
},
},
};
}

View file

@ -2,27 +2,22 @@ import type { App, Component, EventRef } from "obsidian";
import type { ComponentChild as UiNode } from "preact";
import type { AppServerClient } from "../../app-server/connection/client";
import type { SharedServerMetadata } from "../../domain/server/metadata";
import type { ArchiveExportAdapter } from "../thread-export/archive-markdown";
import type { Thread } from "../../domain/threads/model";
import type { CodexPanelSettings } from "../../settings/model";
import type { RuntimeSnapshot } from "./runtime/snapshot";
import type { ChatState, ChatStateStore } from "./state/reducer";
import type { ChatTurnDiffViewState } from "./turn-diff/model";
import type { ChatMessageScrollIntentController } from "./ui/message-stream/scroll-intent-controller";
import type { ChatStateStore } from "./state/reducer";
import type { ChatMessageScrollIntentState } from "./ui/message-stream/scroll-intent-state";
import type { DisplayDetailSection, DisplayItem } from "./display/types";
import type { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks } from "./lifecycle";
import type { ComposerMetaViewModel } from "./ui/composer";
import type { CodexChatHost } from "./chat-host";
export interface ChatControllerCompositionPorts {
obsidian: ChatPanelObsidianContext;
plugin: ChatControllerHostContext;
plugin: CodexChatHost;
state: ChatPanelStateContext;
client: ChatPanelClientContext;
lifecycle: ChatPanelLifecycleContext;
render: ChatPanelRenderContext;
messageStream: ChatPanelMessageStreamContext;
composerView: ChatPanelComposerContext;
surface: ChatPanelSurfaceContext;
runtime: ChatPanelRuntimeContext;
thread: ChatThreadContext;
liveState: ChatPanelLiveStateContext;
@ -30,23 +25,6 @@ export interface ChatControllerCompositionPorts {
status: ChatPanelStatusContext;
}
interface ChatControllerHostContext {
settings: CodexPanelSettings;
vaultPath: string;
openThreadInNewView: (threadId: string) => Promise<unknown>;
focusThreadInOpenView: (threadId: string) => Promise<boolean>;
openTurnDiff: (state: ChatTurnDiffViewState) => Promise<void>;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string | null) => void;
refreshThreadsViewLiveState: () => void;
refreshSharedThreadListFromOpenSurface: () => void;
applyThreadListSnapshot: (threads: readonly Thread[]) => void;
publishAppServerMetadata: (metadata: SharedServerMetadata) => void;
publishAppServerIdentity: (userAgent: string | null) => void;
cachedThreadList: () => readonly Thread[] | null;
cachedAppServerMetadata: () => SharedServerMetadata | null;
}
interface ChatPanelObsidianContext {
app: App;
owner: Component;
@ -58,7 +36,6 @@ interface ChatPanelObsidianContext {
interface ChatPanelStateContext {
stateStore: ChatStateStore;
getState: () => ChatState;
systemItem: (text: string) => DisplayItem;
structuredSystemItem: (text: string, details: DisplayDetailSection[]) => DisplayItem;
}
@ -73,17 +50,12 @@ interface ChatPanelLifecycleContext {
deferredTasks: ChatViewDeferredTasks;
resumeWork: ChatResumeWorkTracker;
connectionWork: ChatConnectionWorkTracker;
messageScrollIntent: ChatMessageScrollIntentController;
messageScrollIntent: ChatMessageScrollIntentState;
getOpened: () => boolean;
setOpened: (opened: boolean) => void;
getClosing: () => boolean;
setClosing: (closing: boolean) => void;
invalidateConnectionWork: () => void;
scheduleDeferredDiagnostics: () => void;
clearDeferredDiagnostics: () => void;
scheduleDeferredRestoredThreadHydration: () => void;
clearDeferredRestoredThreadHydration: () => void;
scheduleDeferredAppServerWarmup: () => void;
refreshDeferredDiagnostics: () => Promise<void>;
}
interface ChatPanelRenderContext {
@ -96,17 +68,13 @@ interface ChatPanelRenderContext {
schedule: () => void;
}
interface ChatPanelMessageStreamContext {
interface ChatPanelSurfaceContext {
pendingRequestsSignature: () => string;
}
interface ChatPanelComposerContext {
composerPlaceholder: () => string;
composerMetaViewModel: () => ComposerMetaViewModel;
}
interface ChatPanelRuntimeContext {
runtimeSnapshotForState: (state: ChatState) => RuntimeSnapshot;
collaborationModeLabel: () => string;
connectionDiagnosticDetails: () => DisplayDetailSection[];
modelStatusLines: () => string[];

View file

@ -6,24 +6,25 @@ import type { ChatComposerController } from "./conversation/composer/controller"
import type { ChatInboundController } from "./protocol/inbound/controller";
import type { GoalActions } from "./threads/goal-actions";
import { createChatRuntimeSettingsActions, type ChatRuntimeSettingsActions } from "./runtime/settings-actions";
import type { ChatThreadActions } from "./threads/actions";
import type { ChatThreadActions } from "./threads/action-context";
import type { AutoTitleController } from "./threads/auto-title-controller";
import type { HistoryController } from "./threads/history-controller";
import type { RenameController } from "./threads/rename-controller";
import type { ToolbarPanelController } from "./panel/regions/toolbar";
import type { ToolbarPanelActions } from "./panel/regions/toolbar";
import type { ChatConnectionController } from "./connection/connection-controller";
import type { ChatReconnectActions } from "./connection/reconnect-actions";
import type { PendingRequestController } from "./conversation/pending-requests/controller";
import type { DisplayDetailSection } from "./display/types";
import { rejectServerRequest, respondToServerRequest } from "./protocol/server-requests/responder";
import type { ComposerSubmitActions } from "./conversation/turns/composer-submit-actions";
import type { RestorationController } from "./threads/restoration-controller";
import type { IdentitySync } from "./threads/identity-sync";
import type { ResumeController } from "./threads/resume-controller";
import type { SelectionActions } from "./threads/selection-actions";
import type { ChatViewRenderController } from "./panel/view-render-controller";
import type { MessageStreamRenderer } from "./ui/message-stream/renderer";
import type { ChatControllerCompositionPorts } from "./composition-ports";
import { createChatControllerCompositionActions } from "./composition-actions";
import { scheduleAppServerWarmup } from "./connection/app-server-warmup";
import { runtimeSnapshotForChatState } from "./runtime/snapshot";
import {
createChatServerActionControllers,
createChatConnectionControllers,
@ -32,11 +33,7 @@ import {
} from "./connection/composition";
import { createThreadControllerGroup, createThreadSelectionActionGroup } from "./threads/composition";
import { createConversationSurfaceControllerGroup } from "./conversation/composition";
import {
createConnectionLifecycleControllerGroup,
createPanelUiControllerGroup,
createViewRenderControllerGroup,
} from "./panel/composition";
import { createChatViewRenderer, createConnectionLifecycleControllerGroup, createPanelUiControllerGroup } from "./panel/composition";
export interface ChatViewControllers {
connection: {
@ -71,14 +68,14 @@ export interface ChatViewControllers {
pending: PendingRequestController;
};
toolbar: {
panels: ToolbarPanelController;
panels: ToolbarPanelActions;
};
composer: {
controller: ChatComposerController;
submission: ComposerSubmitActions;
};
render: {
controller: ChatViewRenderController;
now: () => void;
messageStream: MessageStreamRenderer;
openView: () => void;
closeView: () => void;
@ -86,99 +83,68 @@ export interface ChatViewControllers {
};
}
interface ChatCompositionSideEffects {
render: Pick<ChatControllerCompositionPorts["render"], "panelRoot" | "closeToolbarPanelOnOutsidePointer" | "schedule"> & {
now: () => void;
};
status: ChatControllerCompositionPorts["status"] & {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
};
composer: {
setText: (text: string) => void;
};
}
export function createChatViewControllers(ports: ChatControllerCompositionPorts): ChatViewControllers {
const connection = new ConnectionManager(() => ports.plugin.settings.codexPath, ports.plugin.vaultPath);
const { renderController } = createViewRenderControllerGroup({
plugin: {
settings: ports.plugin.settings,
},
state: {
stateStore: ports.state.stateStore,
},
lifecycle: {
deferredTasks: ports.lifecycle.deferredTasks,
},
render: {
panelRoot: ports.render.panelRoot,
toolbarNode: ports.render.toolbarNode,
goalNode: ports.render.goalNode,
messageStreamNode: ports.render.messageStreamNode,
composerNode: ports.render.composerNode,
},
});
const renderNow = createChatViewRenderer(ports);
let connectionController: ChatConnectionController | null = null;
let selection: SelectionActions | null = null;
let composerController: ChatComposerController | null = null;
const actions = createChatControllerCompositionActions(
{
state: ports.state,
client: ports.client,
render: ports.render,
status: ports.status,
scroll: ports.scroll,
thread: ports.thread,
const ensureConnected = () => requireComposedController(connectionController, "connection controller").ensureConnected();
const refreshThreads = () => requireComposedController(connectionController, "connection controller").refreshThreads();
const refreshSkills = (forceReload?: boolean) =>
requireComposedController(connectionController, "connection controller").refreshSkills(forceReload);
const selectThread = (threadId: string) => requireComposedController(selection, "selection actions").selectThread(threadId);
const sideEffects = createChatCompositionSideEffects(ports, {
renderNow,
setComposerText: (text) => {
requireComposedController(composerController, "composer controller").setDraft(text, { focus: true });
},
{
renderController,
ensureConnected: () => requireComposedController(connectionController, "connection controller").ensureConnected(),
refreshThreads: () => requireComposedController(connectionController, "connection controller").refreshThreads(),
refreshSkills: (forceReload) => requireComposedController(connectionController, "connection controller").refreshSkills(forceReload),
selectThread: (threadId) => requireComposedController(selection, "selection actions").selectThread(threadId),
setComposerText: (text) => {
requireComposedController(composerController, "composer controller").setDraft(text, { focus: true });
},
},
);
});
const runtimeSettings = createChatRuntimeSettingsActions({
stateStore: ports.state.stateStore,
currentClient: ports.client.getClient,
runtimeSnapshotForState: ports.runtime.runtimeSnapshotForState,
runtimeSnapshotForState: runtimeSnapshotForChatState,
collaborationModeLabel: ports.runtime.collaborationModeLabel,
addSystemMessage: actions.status.addSystemMessage,
addSystemMessage: sideEffects.status.addSystemMessage,
});
const scheduleWarmup = () => {
scheduleAppServerWarmup({
deferredTasks: ports.lifecycle.deferredTasks,
opened: ports.lifecycle.getOpened,
closing: ports.lifecycle.getClosing,
connected: () => connection.isConnected(),
ensureConnected,
});
};
const threadControllers = createThreadControllerGroup(
{
obsidian: {
archiveAdapter: ports.obsidian.archiveAdapter,
...ports,
client: {
getClient: ports.client.getClient,
ensureConnected,
},
plugin: {
notifyThreadArchived: (threadId) => {
ports.plugin.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
ports.plugin.notifyThreadRenamed(threadId, name);
},
openThreadInNewView: (threadId) => ports.plugin.openThreadInNewView(threadId),
refreshSharedThreadListFromOpenSurface: () => {
ports.plugin.refreshSharedThreadListFromOpenSurface();
},
settings: ports.plugin.settings,
vaultPath: ports.plugin.vaultPath,
},
state: {
stateStore: ports.state.stateStore,
},
client: actions.client,
lifecycle: {
deferredTasks: ports.lifecycle.deferredTasks,
resumeWork: ports.lifecycle.resumeWork,
getOpened: ports.lifecycle.getOpened,
getClosing: ports.lifecycle.getClosing,
clearDeferredRestoredThreadHydration: ports.lifecycle.clearDeferredRestoredThreadHydration,
},
render: actions.render,
status: actions.status,
render: sideEffects.render,
status: sideEffects.status,
thread: {
selectThread: actions.thread.selectThread,
refreshThreads: actions.thread.refreshThreads,
notifyIdentityChanged: ports.thread.notifyIdentityChanged,
refreshTabHeader: ports.thread.refreshTabHeader,
...ports.thread,
selectThread,
refreshThreads,
},
liveState: {
refresh: ports.liveState.refresh,
},
scroll: actions.scroll,
composer: actions.composer,
scroll: ports.scroll,
composer: sideEffects.composer,
},
{
connection,
@ -194,21 +160,33 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
setOpened: ports.lifecycle.setOpened,
getClosing: ports.lifecycle.getClosing,
setClosing: ports.lifecycle.setClosing,
invalidateConnectionWork: ports.lifecycle.invalidateConnectionWork,
invalidateConnectionWork: () => {
ports.lifecycle.connectionWork.invalidate();
},
invalidateResumeWork: threadControllers.invalidateResumeWork,
scheduleDeferredDiagnostics: ports.lifecycle.scheduleDeferredDiagnostics,
clearDeferredDiagnostics: ports.lifecycle.clearDeferredDiagnostics,
scheduleDeferredRestoredThreadHydration: ports.lifecycle.scheduleDeferredRestoredThreadHydration,
clearDeferredRestoredThreadHydration: ports.lifecycle.clearDeferredRestoredThreadHydration,
scheduleDeferredAppServerWarmup: ports.lifecycle.scheduleDeferredAppServerWarmup,
scheduleDeferredDiagnostics: () => {
ports.lifecycle.deferredTasks.scheduleDiagnostics(() => {
void ports.lifecycle.refreshDeferredDiagnostics();
});
},
clearDeferredDiagnostics: () => {
ports.lifecycle.deferredTasks.clearDiagnostics();
},
scheduleDeferredRestoredThreadHydration: () => {
restoration.scheduleHydration();
},
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
scheduleDeferredAppServerWarmup: () => {
scheduleWarmup();
},
};
const { toolbarPanels, applyViewState } = createPanelUiControllerGroup(
{
state: {
stateStore: ports.state.stateStore,
},
state: ports.state,
lifecycle: lifecycleActions,
render: actions.render,
render: sideEffects.render,
thread: {
restorePlaceholder: (restoredThreadState) => {
restoration.restore(restoredThreadState);
@ -224,16 +202,12 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
);
selection = createThreadSelectionActionGroup(
{
plugin: {
focusThreadInOpenView: (threadId) => ports.plugin.focusThreadInOpenView(threadId),
},
state: {
stateStore: ports.state.stateStore,
},
plugin: ports.plugin,
state: ports.state,
thread: {
resumeThread: (threadId) => resume.resumeThread(threadId),
},
status: actions.status,
status: sideEffects.status,
},
{
closeForThreadSelection: () => {
@ -243,13 +217,14 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
).selection;
const { reconnectActions } = createChatReconnectControllerGroup(
{
state: {
stateStore: ports.state.stateStore,
state: ports.state,
client: {
clear: ports.client.clear,
ensureConnected,
},
client: actions.client,
lifecycle: lifecycleActions,
render: actions.render,
status: actions.status,
render: sideEffects.render,
status: sideEffects.status,
thread: {
resumeThread: (threadId) => resume.resumeThread(threadId),
},
@ -258,50 +233,22 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
connection,
},
);
const serverActionControllers = createChatServerActionControllers(
{
plugin: {
applyThreadListSnapshot: (threads) => {
ports.plugin.applyThreadListSnapshot(threads);
},
publishAppServerMetadata: (metadata) => {
ports.plugin.publishAppServerMetadata(metadata);
},
vaultPath: ports.plugin.vaultPath,
},
state: {
stateStore: ports.state.stateStore,
},
runtime: {
runtimeSnapshotForState: ports.runtime.runtimeSnapshotForState,
},
},
{
connection,
goals,
},
);
const serverActionControllers = createChatServerActionControllers(ports, {
connection,
goals,
});
const { serverThreads, serverMetadata, serverDiagnostics } = serverActionControllers;
const serverRequestHost = {
currentClient: ports.client.getClient,
};
const inboundController = createChatInboundController(
{
plugin: {
notifyThreadArchived: (threadId) => {
ports.plugin.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
ports.plugin.notifyThreadRenamed(threadId, name);
},
},
state: {
stateStore: ports.state.stateStore,
},
render: actions.render,
plugin: ports.plugin,
state: ports.state,
render: sideEffects.render,
thread: {
refreshThreads: actions.thread.refreshThreads,
refreshSkills: actions.thread.refreshSkills,
refreshThreads,
refreshSkills,
publishAppServerMetadataSnapshot: () => {
serverMetadata.publishAppServerMetadataSnapshot();
},
@ -317,16 +264,11 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
);
connectionController = createChatConnectionControllers(
{
plugin: {
publishAppServerIdentity: (userAgent) => {
ports.plugin.publishAppServerIdentity(userAgent);
},
settings: ports.plugin.settings,
plugin: ports.plugin,
state: ports.state,
client: {
setClient: ports.client.setClient,
},
state: {
stateStore: ports.state.stateStore,
},
client: actions.client,
lifecycle: lifecycleActions,
thread: {
loadSharedThreadList: ports.thread.loadSharedThreadList,
@ -335,11 +277,9 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
autoTitle.resetThreadTurnPresence(hadTurns);
},
},
status: actions.status,
liveState: {
refresh: ports.liveState.refresh,
},
render: actions.render,
status: sideEffects.status,
liveState: ports.liveState,
render: sideEffects.render,
},
{
connection,
@ -352,16 +292,16 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
onNotification: (notification) => {
inboundController.handleNotification(notification);
ports.liveState.refresh();
actions.render.schedule();
sideEffects.render.schedule();
},
onServerRequest: (request) => {
inboundController.handleServerRequest(request);
ports.liveState.refresh();
actions.render.now();
sideEffects.render.now();
},
onLog: (message) => {
inboundController.handleAppServerLog(message);
actions.render.now();
sideEffects.render.now();
},
onExit: () => {
connectionController.handleExit();
@ -370,54 +310,25 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
const conversationControllers = createConversationSurfaceControllerGroup(
{
obsidian: {
app: ports.obsidian.app,
owner: ports.obsidian.owner,
viewId: ports.obsidian.viewId,
...ports,
client: {
getClient: ports.client.getClient,
ensureConnected,
},
plugin: {
openTurnDiff: (state) => ports.plugin.openTurnDiff(state),
settings: ports.plugin.settings,
vaultPath: ports.plugin.vaultPath,
},
state: {
stateStore: ports.state.stateStore,
getState: ports.state.getState,
},
client: actions.client,
render: actions.render,
render: sideEffects.render,
runtime: {
runtimeSnapshotForState: ports.runtime.runtimeSnapshotForState,
statusSummaryLines: ports.runtime.statusSummaryLines,
connectionDiagnosticDetails: ports.runtime.connectionDiagnosticDetails,
modelStatusLines: ports.runtime.modelStatusLines,
effortStatusLines: ports.runtime.effortStatusLines,
...ports.runtime,
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
},
thread: {
ensureRestoredThreadLoaded: ports.thread.ensureRestoredThreadLoaded,
startNewThread: ports.thread.startNewThread,
selectThread: actions.thread.selectThread,
notifyIdentityChanged: ports.thread.notifyIdentityChanged,
...ports.thread,
selectThread,
resetTurnPresence: (hadTurns) => {
autoTitle.resetThreadTurnPresence(hadTurns);
},
},
status: actions.status,
scroll: actions.scroll,
lifecycle: {
messageScrollIntent: ports.lifecycle.messageScrollIntent,
},
messageStream: {
pendingRequestsSignature: ports.messageStream.pendingRequestsSignature,
},
composerView: {
composerPlaceholder: ports.composerView.composerPlaceholder,
composerMetaViewModel: ports.composerView.composerMetaViewModel,
},
liveState: {
refresh: ports.liveState.refresh,
},
status: sideEffects.status,
scroll: ports.scroll,
},
{
controller: inboundController,
@ -432,40 +343,14 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
const { pendingRequests, composerSubmit } = conversationControllers;
const { messageStreamRenderer } = conversationControllers;
composerController = conversationControllers.composerController;
const { scheduleAppServerWarmup, openView, closeView } = createConnectionLifecycleControllerGroup(
const lifecycleControllers = createConnectionLifecycleControllerGroup(
{
obsidian: {
registerEvent: ports.obsidian.registerEvent,
registerPointerDown: ports.obsidian.registerPointerDown,
},
plugin: {
cachedThreadList: () => ports.plugin.cachedThreadList(),
cachedAppServerMetadata: () => ports.plugin.cachedAppServerMetadata(),
},
...ports,
client: {
clear: actions.client.clear,
ensureConnected: actions.client.ensureConnected,
},
lifecycle: {
deferredTasks: lifecycleActions.deferredTasks,
getOpened: lifecycleActions.getOpened,
setOpened: lifecycleActions.setOpened,
getClosing: lifecycleActions.getClosing,
setClosing: lifecycleActions.setClosing,
invalidateConnectionWork: lifecycleActions.invalidateConnectionWork,
invalidateResumeWork: lifecycleActions.invalidateResumeWork,
scheduleDeferredRestoredThreadHydration: lifecycleActions.scheduleDeferredRestoredThreadHydration,
scheduleDeferredAppServerWarmup: lifecycleActions.scheduleDeferredAppServerWarmup,
},
render: {
panelRoot: actions.render.panelRoot,
closeToolbarPanelOnOutsidePointer: actions.render.closeToolbarPanelOnOutsidePointer,
now: actions.render.now,
},
liveState: {
refresh: ports.liveState.refresh,
deferRefresh: ports.liveState.deferRefresh,
clear: ports.client.clear,
},
lifecycle: lifecycleActions,
render: sideEffects.render,
},
{
connection,
@ -481,7 +366,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
manager: connection,
controller: connectionController,
reconnect: reconnectActions,
scheduleWarmup: scheduleAppServerWarmup,
scheduleWarmup,
},
inbound: {
controller: inboundController,
@ -516,10 +401,10 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
submission: composerSubmit,
},
render: {
controller: renderController,
now: sideEffects.render.now,
messageStream: messageStreamRenderer,
openView,
closeView,
openView: lifecycleControllers.openView,
closeView: lifecycleControllers.closeView,
applyViewState,
},
};
@ -529,3 +414,45 @@ function requireComposedController<T>(controller: T | null, name: string): T {
if (!controller) throw new Error(`Chat view controller composition did not initialize ${name}.`);
return controller;
}
function createChatCompositionSideEffects(
ports: Pick<ChatControllerCompositionPorts, "render" | "state" | "status">,
deps: {
renderNow: () => void;
setComposerText: (text: string) => void;
},
): ChatCompositionSideEffects {
const render = {
panelRoot: ports.render.panelRoot,
closeToolbarPanelOnOutsidePointer: ports.render.closeToolbarPanelOnOutsidePointer,
schedule: ports.render.schedule,
now: () => {
deps.renderNow();
},
};
const status = {
set: ports.status.set,
addSystemMessage: (text: string) => {
ports.state.stateStore.dispatch({ type: "message-stream/system-item-added", item: ports.state.systemItem(text) });
render.now();
},
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => {
ports.state.stateStore.dispatch({
type: "message-stream/system-item-added",
item: ports.state.structuredSystemItem(text, details),
});
render.now();
},
};
return {
render,
status,
composer: {
setText: (text) => {
deps.setComposerText(text);
render.now();
},
},
};
}

View file

@ -1,11 +1,6 @@
import { Notice } from "obsidian";
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { RuntimeSnapshot } from "../runtime/snapshot";
import type { ChatState, ChatStateStore } from "../state/reducer";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { Thread } from "../../../domain/threads/model";
import type { CodexPanelSettings } from "../../../settings/model";
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "./server-actions/diagnostics";
import { createChatServerMetadataActions, type ChatServerMetadataActions } from "./server-actions/metadata";
import { createChatServerThreadActions } from "./server-actions/threads";
@ -16,20 +11,10 @@ import type { GoalActions } from "../threads/goal-actions";
import type { AutoTitleController } from "../threads/auto-title-controller";
import { ChatInboundController } from "../protocol/inbound/controller";
import type { ChatConnectionWorkTracker } from "../lifecycle";
import type { ChatControllerCompositionPorts } from "../composition-ports";
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
interface ChatServerActionControllerPorts {
plugin: {
applyThreadListSnapshot: (threads: readonly Thread[]) => void;
publishAppServerMetadata: (metadata: SharedServerMetadata) => void;
vaultPath: string;
};
state: {
stateStore: ChatStateStore;
};
runtime: {
runtimeSnapshotForState: (state: ChatState) => RuntimeSnapshot;
};
}
type ChatServerActionControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state">;
export function createChatServerActionControllers(
context: ChatServerActionControllerPorts,
@ -38,7 +23,7 @@ export function createChatServerActionControllers(
goals: GoalActions;
},
) {
const { plugin, runtime } = context;
const { plugin } = context;
const { stateStore } = context.state;
const currentClient = () => refs.connection.currentClient();
const serverMetadata = createChatServerMetadataActions({
@ -62,7 +47,7 @@ export function createChatServerActionControllers(
stateStore,
vaultPath: plugin.vaultPath,
currentClient,
runtimeSnapshotForState: runtime.runtimeSnapshotForState,
runtimeSnapshotForState: runtimeSnapshotForChatState,
publishThreadList: (threads) => {
plugin.applyThreadListSnapshot(threads);
},
@ -74,14 +59,7 @@ export function createChatServerActionControllers(
return { serverThreads, serverMetadata, serverDiagnostics };
}
interface ChatInboundControllerPorts {
plugin: {
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string | null) => void;
};
state: {
stateStore: ChatStateStore;
};
type ChatInboundControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state"> & {
render: {
schedule: () => void;
};
@ -90,7 +68,7 @@ interface ChatInboundControllerPorts {
refreshSkills: (forceReload?: boolean) => Promise<void>;
publishAppServerMetadataSnapshot: () => void;
};
}
};
export function createChatInboundController(
context: ChatInboundControllerPorts,
@ -127,14 +105,7 @@ export function createChatInboundController(
});
}
interface ChatConnectionControllerPorts {
plugin: {
publishAppServerIdentity: (userAgent: string | null) => void;
settings: CodexPanelSettings;
};
state: {
stateStore: ChatStateStore;
};
type ChatConnectionControllerPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state" | "liveState"> & {
client: {
setClient: (client: ReturnType<ConnectionManager["currentClient"]>) => void;
};
@ -153,14 +124,11 @@ interface ChatConnectionControllerPorts {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
liveState: {
refresh: () => void;
};
render: {
now: () => void;
schedule: () => void;
};
}
};
export function createChatConnectionControllers(
context: ChatConnectionControllerPorts,
@ -194,7 +162,9 @@ export function createChatConnectionControllers(
resetThreadTurnPresence: thread.resetTurnPresence,
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
publishAppServerIdentity: plugin.publishAppServerIdentity,
publishAppServerIdentity: (userAgent) => {
plugin.publishAppServerIdentity(userAgent);
},
configuredCommand: () => plugin.settings.codexPath,
refreshLiveState: liveState.refresh,
render: render.now,
@ -206,10 +176,7 @@ export function createChatConnectionControllers(
};
}
interface ChatReconnectControllerGroupPorts {
state: {
stateStore: ChatStateStore;
};
type ChatReconnectControllerGroupPorts = Pick<ChatControllerCompositionPorts, "state"> & {
client: {
clear: () => void;
ensureConnected: () => Promise<void>;
@ -229,7 +196,7 @@ interface ChatReconnectControllerGroupPorts {
thread: {
resumeThread: (threadId: string) => Promise<void>;
};
}
};
export function createChatReconnectControllerGroup(
context: ChatReconnectControllerGroupPorts,

View file

@ -1,9 +1,6 @@
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 ChatState, type ChatStateStore } from "../state/reducer";
import { activeTurnId } from "../state/reducer";
import type { ChatReconnectActions } from "../connection/reconnect-actions";
import { PendingRequestController } from "./pending-requests/controller";
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
@ -11,80 +8,45 @@ 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/actions";
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 type { RuntimeSnapshot } from "../runtime/snapshot";
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
import { MessageStreamRenderer } from "../ui/message-stream/renderer";
import type { ChatControllerCompositionPorts } from "../composition-ports";
import type { DisplayDetailSection } from "../display/types";
import type { ChatMessageScrollIntentController } from "../ui/message-stream/scroll-intent-controller";
import type { ChatTurnDiffViewState } from "../turn-diff/model";
import type { ComposerMetaViewModel } from "../ui/composer";
import type { CodexPanelSettings } from "../../../settings/model";
interface ConversationSurfaceControllerGroupPorts {
obsidian: {
app: App;
owner: Component;
viewId: string;
};
plugin: {
openTurnDiff: (state: ChatTurnDiffViewState) => Promise<void>;
settings: CodexPanelSettings;
vaultPath: string;
};
state: {
stateStore: ChatStateStore;
getState: () => ChatState;
};
type ConversationSurfaceControllerGroupPorts = Pick<
ChatControllerCompositionPorts,
"obsidian" | "plugin" | "state" | "lifecycle" | "surface" | "runtime" | "liveState"
> & {
client: {
getClient: () => AppServerClient | null;
getClient: ChatControllerCompositionPorts["client"]["getClient"];
ensureConnected: () => Promise<void>;
};
lifecycle: {
messageScrollIntent: ChatMessageScrollIntentController;
};
render: {
now: () => void;
schedule: () => void;
};
messageStream: {
pendingRequestsSignature: () => string;
};
composerView: {
composerPlaceholder: () => string;
composerMetaViewModel: () => ComposerMetaViewModel;
};
runtime: {
runtimeSnapshotForState: (state: ChatState) => 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;
followBottom: () => void;
scroll: Pick<ChatControllerCompositionPorts["scroll"], "forceBottom" | "followBottom">;
thread: {
ensureRestoredThreadLoaded: ChatControllerCompositionPorts["thread"]["ensureRestoredThreadLoaded"];
startNewThread: ChatControllerCompositionPorts["thread"]["startNewThread"];
selectThread: (threadId: string) => Promise<void>;
notifyIdentityChanged: () => void;
resetTurnPresence: (hadTurns: boolean) => void;
};
}
runtime: ChatControllerCompositionPorts["runtime"] & {
mcpStatusLines: () => Promise<string[]>;
};
};
export function createConversationSurfaceControllerGroup(
context: ConversationSurfaceControllerGroupPorts,
@ -98,7 +60,7 @@ export function createConversationSurfaceControllerGroup(
history: HistoryController;
},
) {
const { plugin, state, render, messageStream, composerView, runtime, thread, liveState, status, lifecycle, client, scroll } = context;
const { plugin, state, render, surface, runtime, thread, liveState, status, lifecycle, client, scroll } = context;
const { app, owner, viewId } = context.obsidian;
const stateStore = state.stateStore;
const currentClient = client.getClient;
@ -111,14 +73,14 @@ export function createConversationSurfaceControllerGroup(
sendShortcut: () => plugin.settings.sendShortcut,
scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges,
canInterrupt: () => {
const current = state.getState();
const current = stateStore.getState();
return current.turn.lifecycle.kind !== "idle" && Boolean(current.activeThread.id && activeTurnId(current));
},
composerPlaceholder: composerView.composerPlaceholder,
composerMeta: composerView.composerMetaViewModel,
composerPlaceholder: surface.composerPlaceholder,
composerMeta: surface.composerMetaViewModel,
currentModelForSuggestions: () => {
const current = state.getState();
return currentModel(runtime.runtimeSnapshotForState(current), runtimeConfigOrDefault(current.connection.runtimeConfig));
const current = stateStore.getState();
return currentModel(runtimeSnapshotForChatState(current), runtimeConfigOrDefault(current.connection.runtimeConfig));
},
togglePlan: () => void refs.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
@ -128,6 +90,16 @@ export function createConversationSurfaceControllerGroup(
messageStreamRenderer.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 pendingRequests = new PendingRequestController({
stateStore,
controller: refs.controller,
@ -138,93 +110,60 @@ export function createConversationSurfaceControllerGroup(
const turnSubmission = new TurnSubmissionController({
stateStore,
connection: {
vaultPath: plugin.vaultPath,
currentClient,
},
restoredThread: {
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
},
thread: {
startThread: (preview) => refs.serverThreads.startThread(preview),
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
resetThreadTurnPresence: thread.resetTurnPresence,
},
runtime: {
applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(),
},
composer: {
codexInput: (text) => composerController.codexInput(text),
setDraft: (text, options) => {
composerController.setDraft(text, options);
},
},
view: {
render: render.now,
scheduleRender: render.schedule,
},
status: {
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
},
vaultPath: plugin.vaultPath,
currentClient,
ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded,
startThread,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
resetThreadTurnPresence: thread.resetTurnPresence,
applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(),
codexInput,
setDraft: setComposerDraft,
render: render.now,
scheduleRender: render.schedule,
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
});
const slashCommands = createSlashCommandHandler({
stateStore,
currentClient,
codexInput: (text) => composerController.codexInput(text),
threads: {
startNewThread: thread.startNewThread,
startThreadForGoal: async (objective) => {
const response = await refs.serverThreads.startThread(objective, { syncGoal: false });
return response?.threadId ?? null;
},
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(),
},
runtime: {
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(),
},
goals: {
activeGoal: () => refs.goals.activeGoal(),
setObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget),
setStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus),
clear: (threadId) => refs.goals.clear(threadId),
},
status: {
addSystemMessage: status.addSystemMessage,
addStructuredSystemMessage: status.addStructuredSystemMessage,
setStatus: status.set,
statusSummaryLines: runtime.statusSummaryLines,
connectionDiagnosticDetails: runtime.connectionDiagnosticDetails,
mcpStatusLines: runtime.mcpStatusLines,
modelStatusLines: runtime.modelStatusLines,
effortStatusLines: runtime.effortStatusLines,
},
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,
connection: {
currentClient,
ensureConnected: client.ensureConnected,
},
submission: {
sendTurnText: (text) => turnSubmission.sendTurnText(text),
},
runtime: {
requestDefaultCollaborationModeForNextTurn: () => {
refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn();
},
currentClient,
ensureConnected: client.ensureConnected,
sendTurnText: (text) => turnSubmission.sendTurnText(text),
requestDefaultCollaborationModeForNextTurn: () => {
refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn();
},
});
@ -252,7 +191,7 @@ export function createConversationSurfaceControllerGroup(
openTurnDiff: (state) => void plugin.openTurnDiff(state),
},
requests: {
pendingSignature: messageStream.pendingRequestsSignature,
pendingSignature: surface.pendingRequestsSignature,
pendingSnapshot: () => pendingRequests.snapshot(),
pendingActions: () => pendingRequests.actions(),
consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(),

View file

@ -7,41 +7,29 @@ import type { SlashCommandExecutionResult } from "./slash-command-execution";
import type { SlashCommandName } from "../composer/slash-commands";
import type { ReferencedThreadDisplay } from "../../../../domain/threads/reference";
interface ComposerDraftPort {
readonly trimmedDraft: string;
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void;
}
interface ComposerSlashCommandPort {
execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined>;
}
interface ComposerTurnSubmissionPort {
sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadDisplay): Promise<void>;
}
interface ComposerConnectionPort {
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
}
interface ComposerStatusPort {
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
}
interface ComposerScrollPort {
followBottom: () => void;
}
export interface ComposerSubmitActionsHost {
stateStore: ChatStateStore;
composer: ComposerDraftPort;
slashCommands: ComposerSlashCommandPort;
turnSubmission: ComposerTurnSubmissionPort;
connection: ComposerConnectionPort;
status: ComposerStatusPort;
scroll: ComposerScrollPort;
composer: {
readonly trimmedDraft: string;
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void;
};
slashCommands: {
execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined>;
};
turnSubmission: {
sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadDisplay): Promise<void>;
};
connection: {
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
};
status: {
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
};
scroll: {
followBottom: () => void;
};
}
export interface ComposerSubmitActions {

View file

@ -5,24 +5,12 @@ import type { DisplayItem } from "../../display/types";
const IMPLEMENT_PLAN_PROMPT = "Please implement this plan.";
interface PlanImplementationConnectionPort {
currentClient(): AppServerClient | null;
ensureConnected(): Promise<void>;
}
interface PlanImplementationSubmissionPort {
sendTurnText(text: string): Promise<void>;
}
interface PlanImplementationRuntimePort {
requestDefaultCollaborationModeForNextTurn(): void;
}
export interface PlanImplementationHost {
stateStore: ChatStateStore;
connection: PlanImplementationConnectionPort;
submission: PlanImplementationSubmissionPort;
runtime: PlanImplementationRuntimePort;
currentClient(): AppServerClient | null;
ensureConnected(): Promise<void>;
sendTurnText(text: string): Promise<void>;
requestDefaultCollaborationModeForNextTurn(): void;
}
export interface PlanImplementation {
@ -39,10 +27,10 @@ export function createPlanImplementation(host: PlanImplementationHost): PlanImpl
async function implementPlan(host: PlanImplementationHost, item: DisplayItem): Promise<void> {
if (!canImplementPlan(host.stateStore.getState(), item)) return;
await host.connection.ensureConnected();
if (!host.connection.currentClient() || !activeThreadId(host.stateStore.getState())) return;
await host.ensureConnected();
if (!host.currentClient() || !activeThreadId(host.stateStore.getState())) return;
host.runtime.requestDefaultCollaborationModeForNextTurn();
host.requestDefaultCollaborationModeForNextTurn();
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
await host.submission.sendTurnText(IMPLEMENT_PLAN_PROMPT);
await host.sendTurnText(IMPLEMENT_PLAN_PROMPT);
}

View file

@ -3,14 +3,13 @@ import { codexTextInputWithAttachments, type CodexInput } from "../../../../doma
import { readReferencedThreadConversationSummaries } from "../../../../app-server/services/threads";
import { referencedThreadPromptBundle, REFERENCED_THREAD_TURN_LIMIT } from "../../../../domain/threads/reference";
import type { Thread } from "../../../../domain/threads/model";
import type { ThreadGoal, ThreadGoalStatus } from "../../../../domain/threads/goal";
import {
executeSlashCommand as runSlashCommand,
type SlashCommandExecutionContext,
type SlashCommandExecutionResult,
type ThreadReferenceInput,
} from "./slash-command-execution";
import type { SlashCommandName } from "../composer/slash-commands";
import type { DisplayDetailSection } from "../../display/types";
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import { findModelMetadataByIdOrName, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata";
import { submissionStateSnapshot } from "../../state/selectors";
@ -18,54 +17,13 @@ import type { ChatStateStore } from "../../state/reducer";
import { currentModel, runtimeConfigOrDefault } from "../../runtime/effective";
import { runtimeSnapshotForChatState } from "../../runtime/snapshot";
export interface SlashCommandThreadPort {
startNewThread: () => Promise<void>;
startThreadForGoal: (objective: string) => Promise<string | null>;
resumeThread: (threadId: string) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<void>;
reconnect: () => Promise<void>;
}
type DynamicSlashCommandExecutionContext = "activeThreadId" | "busy" | "listedThreads" | "referThread" | "supportedReasoningEfforts";
export interface SlashCommandRuntimePort {
toggleFastMode: () => void | Promise<void>;
toggleCollaborationMode: () => void | Promise<void>;
toggleAutoReview: () => void | Promise<void>;
requestModel: (model: string) => boolean | undefined | Promise<boolean | undefined>;
resetModelToConfig: () => boolean | undefined | Promise<boolean | undefined>;
requestReasoningEffort: (effort: ReasoningEffort) => boolean | undefined | Promise<boolean | undefined>;
resetReasoningEffortToConfig: () => boolean | undefined | Promise<boolean | undefined>;
}
export interface SlashCommandStatusPort {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
setStatus: (status: string) => void;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => DisplayDetailSection[];
mcpStatusLines: () => Promise<string[]>;
modelStatusLines: () => string[];
effortStatusLines: () => string[];
}
export interface SlashCommandGoalPort {
activeGoal: () => ThreadGoal | null;
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clear: (threadId: string) => Promise<boolean>;
}
export interface SlashCommandHandlerHost {
export interface SlashCommandHandlerHost extends Omit<SlashCommandExecutionContext, DynamicSlashCommandExecutionContext> {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
codexInput: (text: string) => CodexInput;
threads: SlashCommandThreadPort;
runtime: SlashCommandRuntimePort;
goals: SlashCommandGoalPort;
status: SlashCommandStatusPort;
setStatus: (status: string) => void;
}
export interface SlashCommandHandler {
@ -87,45 +45,15 @@ async function executeSlashCommand(
const client = host.currentClient();
if (!client && command !== "reconnect" && command !== "compact") return;
return runSlashCommand(command, args, {
...host,
activeThreadId: state.activeThreadId,
listedThreads: state.listedThreads,
startNewThread: () => host.threads.startNewThread(),
startThreadForGoal: (objective) => host.threads.startThreadForGoal(objective),
resumeThread: (threadId) => host.threads.resumeThread(threadId),
reconnect: () => host.threads.reconnect(),
busy: state.busy,
referThread: (thread, message) => {
if (!client) return Promise.resolve(null);
return referencedThreadInput(host, client, thread, message);
},
forkThread: (threadId) => host.threads.forkThread(threadId),
rollbackThread: (threadId) => host.threads.rollbackThread(threadId),
compactThread: (threadId) => host.threads.compactThread(threadId),
archiveThread: (threadId) => host.threads.archiveThread(threadId),
renameThread: (threadId, name) => host.threads.renameThread(threadId, name),
busy: state.busy,
toggleFastMode: () => host.runtime.toggleFastMode(),
toggleCollaborationMode: () => host.runtime.toggleCollaborationMode(),
toggleAutoReview: () => host.runtime.toggleAutoReview(),
addSystemMessage: (text) => {
host.status.addSystemMessage(text);
},
addStructuredSystemMessage: (text, details) => {
host.status.addStructuredSystemMessage(text, details);
},
requestModel: (model) => host.runtime.requestModel(model),
resetModelToConfig: () => host.runtime.resetModelToConfig(),
requestReasoningEffort: (effort) => host.runtime.requestReasoningEffort(effort),
resetReasoningEffortToConfig: () => host.runtime.resetReasoningEffortToConfig(),
supportedReasoningEfforts: () => supportedReasoningEfforts(host.stateStore.getState()),
activeGoal: () => host.goals.activeGoal(),
setGoalObjective: (threadId, objective, tokenBudget) => host.goals.setObjective(threadId, objective, tokenBudget),
setGoalStatus: (threadId, status) => host.goals.setStatus(threadId, status),
clearGoal: (threadId) => host.goals.clear(threadId),
statusSummaryLines: () => host.status.statusSummaryLines(),
connectionDiagnosticDetails: () => host.status.connectionDiagnosticDetails(),
mcpStatusLines: () => host.status.mcpStatusLines(),
modelStatusLines: () => host.status.modelStatusLines(),
effortStatusLines: () => host.status.effortStatusLines(),
});
}
@ -144,18 +72,18 @@ async function referencedThreadInput(
try {
const turns = await readReferencedThreadConversationSummaries(client, thread.id, REFERENCED_THREAD_TURN_LIMIT);
if (turns.length === 0) {
host.status.addSystemMessage("Referenced thread has no readable conversation turns.");
host.addSystemMessage("Referenced thread has no readable conversation turns.");
return null;
}
const reference = referencedThreadPromptBundle(thread, turns, message);
const messageInput = host.codexInput(message);
host.status.setStatus(reference.status);
host.setStatus(reference.status);
return {
input: codexTextInputWithAttachments(reference.prompt, messageInput),
referencedThread: reference.referencedThread,
};
} catch (error) {
host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return null;
}
}

View file

@ -11,59 +11,31 @@ import {
shouldAcknowledgeTurnStart,
} from "./optimistic-turn-start";
export interface TurnSubmissionConnectionPort {
export interface TurnSubmissionControllerHost {
stateStore: ChatStateStore;
vaultPath: string;
currentClient: () => AppServerClient | null;
}
export interface TurnSubmissionRestoredThreadPort {
ensureRestoredThreadLoaded: () => Promise<boolean>;
}
export interface TurnSubmissionThreadPort {
startThread: (preview?: string) => Promise<unknown>;
notifyActiveThreadIdentityChanged: () => void;
resetThreadTurnPresence: (hadTurns: boolean) => void;
}
export interface TurnSubmissionRuntimePort {
applyPendingThreadSettings: () => Promise<boolean>;
}
export interface TurnSubmissionComposerPort {
codexInput: (text: string) => CodexInput;
setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void;
}
export interface TurnSubmissionViewPort {
render: () => void;
scheduleRender: () => void;
}
export interface TurnSubmissionStatusPort {
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
}
export interface TurnSubmissionControllerHost {
stateStore: ChatStateStore;
connection: TurnSubmissionConnectionPort;
restoredThread: TurnSubmissionRestoredThreadPort;
thread: TurnSubmissionThreadPort;
runtime: TurnSubmissionRuntimePort;
composer: TurnSubmissionComposerPort;
view: TurnSubmissionViewPort;
status: TurnSubmissionStatusPort;
}
export class TurnSubmissionController {
private static localItemSequence = 0;
constructor(private readonly host: TurnSubmissionControllerHost) {}
async sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadDisplay): Promise<void> {
if (!(await this.host.restoredThread.ensureRestoredThreadLoaded())) return;
const client = this.host.connection.currentClient();
if (!(await this.host.ensureRestoredThreadLoaded())) return;
const client = this.host.currentClient();
if (!client) return;
const initialState = submissionStateSnapshot(this.host.stateStore.getState());
@ -75,16 +47,16 @@ export class TurnSubmissionController {
let optimisticUserId: string | null = null;
try {
if (!initialState.activeThreadId) {
const threadResponse = await this.host.thread.startThread(text);
const threadResponse = await this.host.startThread(text);
if (!threadResponse) return;
this.host.thread.notifyActiveThreadIdentityChanged();
this.host.thread.resetThreadTurnPresence(false);
this.host.notifyActiveThreadIdentityChanged();
this.host.resetThreadTurnPresence(false);
}
const activeThreadId = submissionStateSnapshot(this.host.stateStore.getState()).activeThreadId;
if (!activeThreadId) return;
if (!(await this.host.runtime.applyPendingThreadSettings())) return;
if (!(await this.host.applyPendingThreadSettings())) return;
const codexInput = codexInputOverride ?? this.host.composer.codexInput(text);
const codexInput = codexInputOverride ?? this.host.codexInput(text);
optimisticUserId = TurnSubmissionController.nextLocalItemId("local-user");
const optimistic = optimisticTurnStart({
id: optimisticUserId,
@ -97,12 +69,12 @@ export class TurnSubmissionController {
item: optimistic.item,
pendingTurnStart: optimistic.pendingTurnStart,
});
this.host.composer.setDraft("");
this.host.view.render();
this.host.setDraft("");
this.host.render();
const response = await client.startTurn({
threadId: activeThreadId,
cwd: this.host.connection.vaultPath,
cwd: this.host.vaultPath,
input: codexInput,
clientUserMessageId: optimisticUserId,
});
@ -125,7 +97,7 @@ export class TurnSubmissionController {
pendingTurnStart: pendingStart,
});
this.host.stateStore.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, displayItems });
this.host.status.setStatus("Turn running...");
this.host.setStatus("Turn running...");
}
} catch (error) {
const failedState = submissionStateSnapshot(this.host.stateStore.getState());
@ -136,11 +108,11 @@ export class TurnSubmissionController {
pendingTurnStart: failedState.pendingTurnStart,
});
this.host.stateStore.dispatch({ type: "turn/start-failed", displayItems });
this.host.composer.setDraft(text);
this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
this.host.setDraft(text);
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
this.host.view.scheduleRender();
this.host.scheduleRender();
}
private async steerCurrentTurn(
@ -153,13 +125,13 @@ export class TurnSubmissionController {
const threadId = state.activeThreadId;
const expectedTurnId = state.activeTurnId;
if (!threadId || !expectedTurnId) {
this.host.status.addSystemMessage("Current turn is not steerable yet.");
this.host.addSystemMessage("Current turn is not steerable yet.");
return;
}
const codexInput = codexInputOverride ?? this.host.composer.codexInput(text);
const codexInput = codexInputOverride ?? this.host.codexInput(text);
const localSteerId = TurnSubmissionController.nextLocalItemId("local-steer");
this.host.composer.setDraft("", { clearSuggestions: true });
this.host.setDraft("", { clearSuggestions: true });
try {
await client.steerTurn(threadId, expectedTurnId, codexInput, localSteerId);
@ -174,14 +146,14 @@ export class TurnSubmissionController {
codexInput,
}),
});
this.host.status.setStatus("Steered current turn.");
this.host.setStatus("Steered current turn.");
} catch (error) {
if (!this.isCurrentTurn(threadId, expectedTurnId)) return;
this.host.composer.setDraft(text, { focus: true });
this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
this.host.setDraft(text, { focus: true });
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
this.host.view.scheduleRender();
this.host.scheduleRender();
}
private isCurrentTurn(threadId: string, turnId: string): boolean {

View file

@ -31,59 +31,64 @@ export type RestoredThreadLifecycleEvent =
| { type: "loading-finished"; loading: Promise<void> }
| { type: "cleared" };
export class ChatViewDeferredTasks {
private readonly restoredThreadHydrationTask: DeferredTask;
private readonly renderTask: DeferredTask;
private readonly diagnosticsTask: DeferredTask;
private readonly appServerWarmupTask: DeferredTask;
export interface ChatViewDeferredTasks {
scheduleRender(callback: () => void): void;
clearRender(): void;
scheduleDiagnostics(callback: () => void): void;
clearDiagnostics(): void;
scheduleRestoredThreadHydration(callback: () => void): void;
clearRestoredThreadHydration(): void;
scheduleAppServerWarmup(callback: () => void): void;
clearAppServerWarmup(): void;
clearAll(): void;
}
constructor(getWindow: () => DeferredTaskWindow) {
this.renderTask = new DeferredTask(getWindow, 50);
this.diagnosticsTask = new DeferredTask(getWindow, 1_000);
this.restoredThreadHydrationTask = new DeferredTask(getWindow, 1_500);
this.appServerWarmupTask = new DeferredTask(getWindow, 0);
}
export function createChatViewDeferredTasks(getWindow: () => DeferredTaskWindow): ChatViewDeferredTasks {
const renderTask = new DeferredTask(getWindow, 50);
const diagnosticsTask = new DeferredTask(getWindow, 1_000);
const restoredThreadHydrationTask = new DeferredTask(getWindow, 1_500);
const appServerWarmupTask = new DeferredTask(getWindow, 0);
scheduleRender(callback: () => void): void {
this.renderTask.schedule(() => {
callback();
});
}
return {
scheduleRender(callback): void {
renderTask.schedule(callback);
},
clearRender(): void {
this.renderTask.clear();
}
clearRender(): void {
renderTask.clear();
},
scheduleDiagnostics(callback: () => void): void {
this.diagnosticsTask.schedule(callback);
}
scheduleDiagnostics(callback): void {
diagnosticsTask.schedule(callback);
},
clearDiagnostics(): void {
this.diagnosticsTask.clear();
}
clearDiagnostics(): void {
diagnosticsTask.clear();
},
scheduleRestoredThreadHydration(callback: () => void): void {
this.restoredThreadHydrationTask.schedule(callback);
}
scheduleRestoredThreadHydration(callback): void {
restoredThreadHydrationTask.schedule(callback);
},
clearRestoredThreadHydration(): void {
this.restoredThreadHydrationTask.clear();
}
clearRestoredThreadHydration(): void {
restoredThreadHydrationTask.clear();
},
scheduleAppServerWarmup(callback: () => void): void {
this.appServerWarmupTask.schedule(callback);
}
scheduleAppServerWarmup(callback): void {
appServerWarmupTask.schedule(callback);
},
clearAppServerWarmup(): void {
this.appServerWarmupTask.clear();
}
clearAppServerWarmup(): void {
appServerWarmupTask.clear();
},
clearAll(): void {
this.clearRestoredThreadHydration();
this.clearAppServerWarmup();
this.clearDiagnostics();
this.clearRender();
}
clearAll(): void {
restoredThreadHydrationTask.clear();
appServerWarmupTask.clear();
diagnosticsTask.clear();
renderTask.clear();
},
};
}
export class ChatConnectionWorkTracker {

View file

@ -1,20 +0,0 @@
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { Thread } from "../../../domain/threads/model";
import type { ChatServerMetadataActions } from "../connection/server-actions/metadata";
import type { ChatServerThreadActions } from "../connection/server-actions/threads";
export interface CachedSharedAppServerStateSource {
cachedThreadList: () => readonly Thread[] | null;
cachedAppServerMetadata: () => SharedServerMetadata | null;
}
export function applyCachedSharedAppServerState(
source: CachedSharedAppServerStateSource,
serverThreads: ChatServerThreadActions,
serverMetadata: ChatServerMetadataActions,
): void {
const threads = source.cachedThreadList();
if (threads) serverThreads.applyThreadList(threads);
const metadata = source.cachedAppServerMetadata();
if (metadata) serverMetadata.applyAppServerMetadata(metadata);
}

View file

@ -1,67 +1,49 @@
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { ComponentChild as UiNode } from "preact";
import type { ChatStateStore } from "../state/reducer";
import type { CodexPanelSettings } from "../../../settings/model";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { Thread } from "../../../domain/threads/model";
import type { ChatServerMetadataActions } from "../connection/server-actions/metadata";
import type { ChatServerThreadActions } from "../connection/server-actions/threads";
import type { ChatComposerController } from "../conversation/composer/controller";
import type { ChatThreadActions } from "../threads/actions";
import { scheduleAppServerWarmup } from "../connection/app-server-warmup";
import type { ChatThreadActions } from "../threads/action-context";
import { closeChatView, openChatView, type ChatViewLifecycleHost } from "./view-lifecycle";
import { createToolbarArchiveConfirmState, ToolbarPanelController } from "./regions/toolbar";
import { ChatViewRenderController } from "./view-render-controller";
import { createToolbarArchiveConfirmState, createToolbarPanelActions } from "./regions/toolbar";
import { applyChatViewState } from "./view-state";
import type { MessageStreamRenderer } from "../ui/message-stream/renderer";
import { applyCachedSharedAppServerState, type CachedSharedAppServerStateSource } from "./cached-app-server-state";
import type { ChatViewDeferredTasks, RestoredThreadState } from "../lifecycle";
import { createChatShellRenderPort } from "./shell-render";
import type { ChatControllerCompositionPorts } from "../composition-ports";
import { renderChatPanelShell } from "../ui/shell";
interface ViewRenderControllerGroupPorts {
plugin: {
settings: CodexPanelSettings;
};
state: {
stateStore: ChatStateStore;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
};
render: {
panelRoot: () => HTMLElement | null;
toolbarNode: () => UiNode;
goalNode: () => UiNode;
messageStreamNode: () => UiNode;
composerNode: () => UiNode;
};
export interface CachedSharedAppServerStateSource {
cachedThreadList: () => readonly Thread[] | null;
cachedAppServerMetadata: () => SharedServerMetadata | null;
}
export function createViewRenderControllerGroup(context: ViewRenderControllerGroupPorts) {
type ChatViewRendererPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state" | "lifecycle" | "render">;
export function createChatViewRenderer(context: ChatViewRendererPorts): () => void {
const { plugin, render, lifecycle } = context;
const { deferredTasks } = lifecycle;
return {
renderController: new ChatViewRenderController({
shell: createChatShellRenderPort(context.state.stateStore, {
showToolbar: () => plugin.settings.showToolbar,
toolbarNode: context.render.toolbarNode,
goalNode: context.render.goalNode,
messageStreamNode: context.render.messageStreamNode,
composerNode: context.render.composerNode,
}),
panelRoot: render.panelRoot,
clearScheduledRender: () => {
deferredTasks.clearRender();
},
}),
return () => {
deferredTasks.clearRender();
const root = render.panelRoot();
if (!root) return;
renderChatPanelShell(root, {
stateStore: context.state.stateStore,
showToolbar: plugin.settings.showToolbar,
toolbarNode: context.render.toolbarNode,
goalNode: context.render.goalNode,
messageStreamNode: context.render.messageStreamNode,
composerNode: context.render.composerNode,
});
};
}
interface ConnectionLifecycleControllerGroupPorts {
obsidian: Pick<ChatViewLifecycleHost, "registerEvent" | "registerPointerDown">;
type ConnectionLifecycleControllerGroupPorts = Pick<ChatControllerCompositionPorts, "plugin" | "liveState"> & {
obsidian: Pick<ChatViewLifecycleHost["events"], "registerEvent" | "registerPointerDown">;
plugin: CachedSharedAppServerStateSource;
client: {
clear: () => void;
ensureConnected: () => Promise<void>;
};
lifecycle: {
deferredTasks: ChatViewDeferredTasks;
@ -83,7 +65,7 @@ interface ConnectionLifecycleControllerGroupPorts {
refresh: () => void;
deferRefresh: () => void;
};
}
};
export function createConnectionLifecycleControllerGroup(
context: ConnectionLifecycleControllerGroupPorts,
@ -98,53 +80,54 @@ export function createConnectionLifecycleControllerGroup(
const { obsidian, plugin, lifecycle, render, liveState, client } = context;
const { deferredTasks } = lifecycle;
const warmupHost = {
deferredTasks,
opened: lifecycle.getOpened,
closing: lifecycle.getClosing,
connected: () => refs.connection.isConnected(),
ensureConnected: client.ensureConnected,
};
const viewLifecycleHost: ChatViewLifecycleHost = {
setOpened: lifecycle.setOpened,
setClosing: lifecycle.setClosing,
registerEvent: obsidian.registerEvent,
registerComposerNoteIndexInvalidation: (register) => {
refs.composerController.registerNoteIndexInvalidation(register);
lifecycle: {
setOpened: lifecycle.setOpened,
setClosing: lifecycle.setClosing,
invalidateConnectionWork: lifecycle.invalidateConnectionWork,
invalidateResumeWork: lifecycle.invalidateResumeWork,
clearDeferredTasks: () => {
deferredTasks.clearAll();
},
scheduleDeferredAppServerWarmup: lifecycle.scheduleDeferredAppServerWarmup,
scheduleDeferredRestoredThreadHydration: lifecycle.scheduleDeferredRestoredThreadHydration,
},
registerPointerDown: obsidian.registerPointerDown,
applyCachedSharedAppServerState: () => {
applyCachedSharedAppServerState(plugin, refs.serverThreads, refs.serverMetadata);
events: {
registerEvent: obsidian.registerEvent,
registerComposerNoteIndexInvalidation: (register) => {
refs.composerController.registerNoteIndexInvalidation(register);
},
registerPointerDown: obsidian.registerPointerDown,
closeToolbarPanelOnOutsidePointer: render.closeToolbarPanelOnOutsidePointer,
},
render: render.now,
scheduleDeferredAppServerWarmup: lifecycle.scheduleDeferredAppServerWarmup,
scheduleDeferredRestoredThreadHydration: lifecycle.scheduleDeferredRestoredThreadHydration,
closeToolbarPanelOnOutsidePointer: render.closeToolbarPanelOnOutsidePointer,
invalidateConnectionWork: lifecycle.invalidateConnectionWork,
invalidateResumeWork: lifecycle.invalidateResumeWork,
clearDeferredTasks: () => {
deferredTasks.clearAll();
render: {
panelRoot: render.panelRoot,
now: render.now,
},
panelRoot: render.panelRoot,
disposeMessages: () => {
refs.messageStreamRenderer.dispose();
sharedState: {
applyCachedAppServerState: () => {
applyCachedSharedAppServerState(plugin, refs.serverThreads, refs.serverMetadata);
},
},
disposeComposer: () => {
refs.composerController.dispose();
resources: {
disposeMessages: () => {
refs.messageStreamRenderer.dispose();
},
disposeComposer: () => {
refs.composerController.dispose();
},
disconnect: () => {
refs.connection.disconnect();
},
clearClient: client.clear,
},
disconnect: () => {
refs.connection.disconnect();
liveState: {
refresh: liveState.refresh,
deferRefresh: liveState.deferRefresh,
},
clearClient: client.clear,
refreshLiveState: liveState.refresh,
deferRefreshLiveState: liveState.deferRefresh,
};
return {
scheduleAppServerWarmup: () => {
scheduleAppServerWarmup(warmupHost);
},
openView: () => {
openChatView(viewLifecycleHost);
},
@ -154,10 +137,7 @@ export function createConnectionLifecycleControllerGroup(
};
}
interface PanelUiControllerGroupPorts {
state: {
stateStore: ChatStateStore;
};
type PanelUiControllerGroupPorts = Pick<ChatControllerCompositionPorts, "state"> & {
lifecycle: {
invalidateResumeWork: () => void;
clearDeferredRestoredThreadHydration: () => void;
@ -170,7 +150,7 @@ interface PanelUiControllerGroupPorts {
clearRestoredLifecycle: () => void;
restorePlaceholder: (restoredThread: RestoredThreadState) => void;
};
}
};
export function createPanelUiControllerGroup(
context: PanelUiControllerGroupPorts,
@ -188,7 +168,7 @@ export function createPanelUiControllerGroup(
restoreThreadPlaceholder: thread.restorePlaceholder,
};
const toolbarPanels = new ToolbarPanelController({
const toolbarPanels = createToolbarPanelActions({
stateStore: context.state.stateStore,
threadActions: refs.threadActions,
archiveConfirm: createToolbarArchiveConfirmState(),
@ -200,3 +180,14 @@ export function createPanelUiControllerGroup(
return { toolbarPanels, applyViewState };
}
function applyCachedSharedAppServerState(
source: CachedSharedAppServerStateSource,
serverThreads: ChatServerThreadActions,
serverMetadata: ChatServerMetadataActions,
): void {
const threads = source.cachedThreadList();
if (threads) serverThreads.applyThreadList(threads);
const metadata = source.cachedAppServerMetadata();
if (metadata) serverMetadata.applyAppServerMetadata(metadata);
}

View file

@ -1,3 +1,6 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import {
autoReviewActive,
currentModel,
@ -18,6 +21,7 @@ import type {
ComposerMetaViewModel,
RuntimeChoice,
} from "../../ui/composer";
import { useChatPanelShellState } from "../../ui/shell";
import { explicitThreadName } from "../../../../domain/threads/model";
import type { ChatPanelComposerPorts, RestoredThreadTitleSnapshot } from "./ports";
@ -33,6 +37,23 @@ export function composerPlaceholder(threadName: string | null): string {
return threadName ? `Ask Codex to work on “${threadName}”...` : "Ask Codex to work on this task...";
}
export function chatPanelComposerRegionNode(node: () => UiNode): UiNode {
return h(ComposerRegion, { node });
}
function ComposerRegion({ node }: { node: () => UiNode }): UiNode {
const { connection, threadList, activeThread, runtime, turn, messageStream, composer, renderVersion } = useChatPanelShellState();
void connection.value;
void threadList.value;
void activeThread.value;
void runtime.value;
void turn.value;
void messageStream.value;
void composer.value;
void renderVersion.value;
return node();
}
export function chatPanelComposerPlaceholder(ports: ChatPanelComposerPorts): string {
return composerPlaceholder(activeComposerThreadName(ports.state.chat(), ports.thread.restoredPlaceholder()));
}

View file

@ -0,0 +1,178 @@
import type { ComponentChild as UiNode } from "preact";
import type { CodexPanelSettings } from "../../../../settings/model";
import type { RuntimeSnapshot } from "../../runtime/snapshot";
import type { ChatViewControllers } from "../../composition";
import { type ChatAction, type ChatState, type ChatStateStore, chatTurnBusy } from "../../state/reducer";
import type { RestoredThreadTitleSnapshot, ChatPanelRegionPorts } from "./ports";
export interface ChatPanelRegionHost {
settings: CodexPanelSettings;
vaultPath: string;
stateStore: ChatStateStore;
runtimeSnapshot: () => RuntimeSnapshot;
restoredThreadPlaceholder: () => RestoredThreadTitleSnapshot | null;
messageStreamNode: () => UiNode;
startNewThread: () => Promise<void>;
}
export function createChatPanelRegionPorts(host: ChatPanelRegionHost, controllers: ChatViewControllers): ChatPanelRegionPorts {
const state = {
chat: () => host.stateStore.getState(),
};
const dispatch = (action: ChatAction): void => {
host.stateStore.dispatch(action);
};
const render = (): void => {
controllers.render.now();
};
const setGoalEditingOpen = (open: boolean, { closeToolbarPanel = false }: { closeToolbarPanel?: boolean } = {}): void => {
if (closeToolbarPanel) dispatch({ type: "ui/panel-set", panel: null });
dispatch({ type: "ui/detail-open-set", key: "goal:editor", open });
render();
};
return {
toolbar: {
state: {
...state,
connected: () => controllers.connection.manager.isConnected(),
turnBusy: () => chatTurnBusy(host.stateStore.getState()),
},
settings: {
vaultPath: () => host.vaultPath,
configuredCommand: () => host.settings.codexPath,
archiveExportEnabled: () => host.settings.archiveExportEnabled,
},
runtime: {
snapshot: host.runtimeSnapshot,
},
view: {
toolbar: {
archiveConfirmId: () => controllers.toolbar.panels.archiveConfirmId(),
archiveConfirmSubscribe: (listener) => controllers.toolbar.panels.onArchiveConfirmChange(listener),
renameState: (threadId) => controllers.thread.rename.editState(threadId),
renameSubscribe: (listener) => controllers.thread.rename.subscribe(listener),
},
},
actions: {
toolbar: {
startNewThread: () => {
void host.startNewThread();
},
toggleChatActions: () => {
controllers.toolbar.panels.toggleChatActions();
},
compactConversation: () => {
void compactConversation(host.stateStore.getState(), controllers);
},
setGoal: () => {
setGoalEditingOpen(true, { closeToolbarPanel: true });
},
toggleHistory: () => {
controllers.toolbar.panels.toggleHistory();
},
toggleStatusPanel: () => {
controllers.toolbar.panels.toggleStatus();
},
connect: () => {
void controllers.connection.reconnect.reconnectPanel();
},
refreshStatus: () => {
void controllers.connection.controller.refreshStatusPanel();
},
resumeThread: (threadId) => {
void controllers.thread.selection.selectThreadFromToolbar(threadId);
},
startArchiveThread: (threadId) => {
controllers.toolbar.panels.startArchive(threadId);
},
archiveThread: (threadId, saveMarkdown) => {
void controllers.toolbar.panels.archiveThread(threadId, saveMarkdown);
},
startRenameThread: (threadId) => {
controllers.thread.rename.start(threadId);
},
updateRenameDraft: (threadId, value) => {
controllers.thread.rename.updateDraft(threadId, value);
},
saveRenameThread: (threadId, value) => {
void controllers.thread.rename.save(threadId, value);
},
cancelRenameThread: (threadId) => {
controllers.thread.rename.cancel(threadId);
},
autoNameThread: (threadId) => {
void controllers.thread.rename.autoNameDraft(threadId);
},
},
},
},
goal: {
state,
settings: {
sendShortcut: () => host.settings.sendShortcut,
},
actions: {
goal: {
saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), controllers, objective, tokenBudget),
setStatus: (threadId, status) => controllers.runtime.goals.setStatus(threadId, status),
clear: (threadId) => controllers.runtime.goals.clear(threadId),
setEditingOpen: (open) => {
setGoalEditingOpen(open);
},
},
},
},
messageStream: {
state,
render: {
node: host.messageStreamNode,
},
},
composer: {
state,
thread: {
restoredPlaceholder: host.restoredThreadPlaceholder,
},
runtime: {
snapshot: host.runtimeSnapshot,
requestModel: (model) => controllers.runtime.settings.requestModelFromUi(model),
requestReasoningEffort: (effort) => controllers.runtime.settings.requestReasoningEffortFromUi(effort),
resetReasoningEffortToConfig: () => controllers.runtime.settings.resetReasoningEffortToConfigFromUi(),
},
},
};
}
async function compactConversation(state: ChatState, controllers: ChatViewControllers): Promise<void> {
const threadId = state.activeThread.id;
if (!threadId) {
controllers.inbound.controller.addSystemMessage("No active thread to compact.");
controllers.render.now();
return;
}
await controllers.thread.actions.compactThread(threadId);
}
async function saveGoalObjective(
state: ChatState,
controllers: ChatViewControllers,
objective: string,
tokenBudget: number | null,
): Promise<void> {
let threadId = state.activeThread.id;
if (!threadId) {
try {
await controllers.connection.controller.ensureConnected();
const response = await controllers.serverActions.threads.startThread(objective, { syncGoal: false });
threadId = response?.threadId ?? null;
} catch (error) {
controllers.inbound.controller.addSystemMessage(error instanceof Error ? error.message : String(error));
controllers.render.now();
return;
}
}
if (!threadId) return;
void controllers.runtime.goals.setObjective(threadId, objective, tokenBudget);
}

View file

@ -1,6 +1,29 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { useComputed } from "@preact/signals";
import type { GoalRegionActions, GoalRegionOptions } from "../../ui/goal";
import { goalRegionNode } from "../../ui/goal";
import { useChatPanelShellState } from "../../ui/shell";
import type { ChatPanelGoalPorts } from "./ports";
export function chatPanelGoalRegionNode(ports: ChatPanelGoalPorts): UiNode {
return h(GoalRegion, { ports });
}
function GoalRegion({ ports }: { ports: ChatPanelGoalPorts }): UiNode {
const { activeThread, ui, renderVersion, latestState } = useChatPanelShellState();
const props = useComputed(() => {
void renderVersion.value;
return chatPanelGoalProps(ports, {
...latestState(),
activeThread: activeThread.value,
ui: ui.value,
});
});
return goalRegionNode(props.value.goal, props.value.actions, props.value.options);
}
export function chatPanelGoalProps(
ports: ChatPanelGoalPorts,
state: ReturnType<ChatPanelGoalPorts["state"]["chat"]>,

View file

@ -1,7 +1,27 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { pendingRequestsSignature as requestStateSignature } from "../../conversation/pending-requests/signatures";
import { useChatPanelShellState } from "../../ui/shell";
import type { ChatPanelMessageStreamPorts } from "./ports";
export function chatPanelMessageStreamNode(ports: ChatPanelMessageStreamPorts) {
export function chatPanelMessageStreamRegionNode(ports: ChatPanelMessageStreamPorts): UiNode {
return h(MessageStreamRegion, { ports });
}
function MessageStreamRegion({ ports }: { ports: ChatPanelMessageStreamPorts }): UiNode {
const { activeThread, runtime, messageStream, requests, turn, ui, renderVersion } = useChatPanelShellState();
void activeThread.value;
void runtime.value;
void messageStream.value;
void requests.value;
void turn.value;
void ui.value;
void renderVersion.value;
return chatPanelMessageStreamNode(ports);
}
function chatPanelMessageStreamNode(ports: ChatPanelMessageStreamPorts) {
return ports.render.node();
}

View file

@ -81,3 +81,10 @@ export interface ChatPanelComposerPorts extends ChatPanelStatePort {
resetReasoningEffortToConfig: () => Promise<void>;
};
}
export interface ChatPanelRegionPorts {
toolbar: ChatPanelToolbarPorts;
goal: ChatPanelGoalPorts;
messageStream: ChatPanelMessageStreamPorts;
composer: ChatPanelComposerPorts;
}

View file

@ -1,95 +0,0 @@
import type { ComponentChild as UiNode } from "preact";
import { useComputed } from "@preact/signals";
import { useEffect, useState } from "preact/hooks";
import { goalRegionNode } from "../../ui/goal";
import { useChatPanelShellState } from "../../ui/shell";
import { toolbarNode } from "../../ui/toolbar";
import { chatPanelGoalProps } from "./goal";
import { chatPanelMessageStreamNode } from "./message-stream";
import type { ChatPanelGoalPorts, ChatPanelMessageStreamPorts, ChatPanelToolbarPorts } from "./ports";
import { chatPanelToolbarViewModel } from "./toolbar";
export function chatPanelToolbarRegionNode(ports: ChatPanelToolbarPorts): UiNode {
return <ToolbarRegion ports={ports} />;
}
export function chatPanelGoalRegionNode(ports: ChatPanelGoalPorts): UiNode {
return <GoalRegion ports={ports} />;
}
export function chatPanelMessageStreamRegionNode(ports: ChatPanelMessageStreamPorts): UiNode {
return <MessageStreamRegion ports={ports} />;
}
export function chatPanelComposerRegionNode(node: () => UiNode): UiNode {
return <ComposerRegion node={node} />;
}
function ToolbarRegion({ ports }: { ports: ChatPanelToolbarPorts }): UiNode {
const shellState = useChatPanelShellState();
useToolbarArchiveConfirmSubscription(ports);
useToolbarRenameSubscription(ports);
void shellState.renderVersion.value;
return toolbarNode(chatPanelToolbarViewModel(ports, shellState), ports.actions.toolbar);
}
function GoalRegion({ ports }: { ports: ChatPanelGoalPorts }): UiNode {
const { activeThread, ui, renderVersion, latestState } = useChatPanelShellState();
const props = useComputed(() => {
void renderVersion.value;
return chatPanelGoalProps(ports, {
...latestState(),
activeThread: activeThread.value,
ui: ui.value,
});
});
return goalRegionNode(props.value.goal, props.value.actions, props.value.options);
}
function MessageStreamRegion({ ports }: { ports: ChatPanelMessageStreamPorts }): UiNode {
const { activeThread, runtime, messageStream, requests, turn, ui, renderVersion } = useChatPanelShellState();
void activeThread.value;
void runtime.value;
void messageStream.value;
void requests.value;
void turn.value;
void ui.value;
void renderVersion.value;
return chatPanelMessageStreamNode(ports);
}
function ComposerRegion({ node }: { node: () => UiNode }): UiNode {
const { connection, threadList, activeThread, runtime, turn, messageStream, composer, renderVersion } = useChatPanelShellState();
void connection.value;
void threadList.value;
void activeThread.value;
void runtime.value;
void turn.value;
void messageStream.value;
void composer.value;
void renderVersion.value;
return node();
}
function useToolbarArchiveConfirmSubscription(ports: ChatPanelToolbarPorts): void {
const [, setVersion] = useState(0);
useEffect(
() =>
ports.view.toolbar.archiveConfirmSubscribe(() => {
setVersion((version) => version + 1);
}),
[ports],
);
}
function useToolbarRenameSubscription(ports: ChatPanelToolbarPorts): void {
const [, setVersion] = useState(0);
useEffect(
() =>
ports.view.toolbar.renameSubscribe(() => {
setVersion((version) => version + 1);
}),
[ports],
);
}

View file

@ -1,12 +1,16 @@
import type { ComponentChild as UiNode } from "preact";
import { h } from "preact";
import { useEffect, useState } from "preact/hooks";
import type { Thread } from "../../../../domain/threads/model";
import { getThreadTitle } from "../../../../domain/threads/model";
import type { ChatThreadActions } from "../../threads/actions";
import type { ChatThreadActions } from "../../threads/action-context";
import { runtimeConfigSections, rateLimitSummary } from "../../display/status/runtime";
import { connectionDiagnosticSections } from "../../display/status/diagnostics";
import type { RuntimeSnapshot } from "../../runtime/snapshot";
import type { ChatAction, ChatState, ChatStateStore } from "../../state/reducer";
import type { ChatPanelShellState } from "../../ui/shell";
import type { ToolbarThreadRow, ToolbarViewModel } from "../../ui/toolbar";
import { useChatPanelShellState, type ChatPanelShellState } from "../../ui/shell";
import { toolbarNode, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar";
import type { ChatPanelToolbarPorts } from "./ports";
export interface ToolbarViewModelInput {
@ -27,7 +31,7 @@ export interface ConnectionDiagnosticsModelInput {
configuredCommand: string;
}
export interface ToolbarPanelControllerHost {
export interface ToolbarPanelActionsHost {
stateStore: ChatStateStore;
threadActions: ChatThreadActions;
archiveConfirm: ToolbarArchiveConfirmState;
@ -40,7 +44,20 @@ export interface ToolbarArchiveConfirmState {
subscribe: (listener: () => void) => () => void;
}
export interface ToolbarOutsidePointerContext {
export interface ToolbarPanelActions {
archiveConfirmId(): string | null;
onArchiveConfirmChange(listener: () => void): () => void;
toggleHistory(): void;
toggleChatActions(): void;
closeToolbarPanels(): void;
toggleStatus(): void;
closeForThreadSelection(): void;
startArchive(threadId: string): void;
archiveThread(threadId: string, saveMarkdown: boolean): Promise<void>;
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void;
}
interface ToolbarOutsidePointerContext {
target: EventTarget | null;
viewWindow: ToolbarDomWindow | null;
contains: (element: Element) => boolean;
@ -49,7 +66,7 @@ export interface ToolbarOutsidePointerContext {
type ToolbarDomWindow = Window & { Element: typeof Element };
export function chatPanelToolbarViewModel(ports: ChatPanelToolbarPorts, shellState: ChatPanelShellState) {
function chatPanelToolbarViewModel(ports: ChatPanelToolbarPorts, shellState: ChatPanelShellState) {
const latestState = shellState.latestState();
return toolbarViewModel({
state: {
@ -72,6 +89,40 @@ export function chatPanelToolbarViewModel(ports: ChatPanelToolbarPorts, shellSta
});
}
export function chatPanelToolbarRegionNode(ports: ChatPanelToolbarPorts): UiNode {
return h(ToolbarRegion, { ports });
}
function ToolbarRegion({ ports }: { ports: ChatPanelToolbarPorts }): UiNode {
const shellState = useChatPanelShellState();
useToolbarArchiveConfirmSubscription(ports);
useToolbarRenameSubscription(ports);
void shellState.renderVersion.value;
return toolbarNode(chatPanelToolbarViewModel(ports, shellState), ports.actions.toolbar);
}
function useToolbarArchiveConfirmSubscription(ports: ChatPanelToolbarPorts): void {
const [, setVersion] = useState(0);
useEffect(
() =>
ports.view.toolbar.archiveConfirmSubscribe(() => {
setVersion((version) => version + 1);
}),
[ports],
);
}
function useToolbarRenameSubscription(ports: ChatPanelToolbarPorts): void {
const [, setVersion] = useState(0);
useEffect(
() =>
ports.view.toolbar.renameSubscribe(() => {
setVersion((version) => version + 1);
}),
[ports],
);
}
export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel {
const { state, snapshot } = input;
const limit = rateLimitSummary(snapshot, Date.now());
@ -137,92 +188,85 @@ export function connectionDiagnosticsModel(input: ConnectionDiagnosticsModelInpu
});
}
export class ToolbarPanelController {
constructor(private readonly host: ToolbarPanelControllerHost) {}
export function createToolbarPanelActions(host: ToolbarPanelActionsHost): ToolbarPanelActions {
const state = (): ChatState => host.stateStore.getState();
const dispatch = (action: ChatAction): void => {
host.stateStore.dispatch(action);
};
const hasOpenPanel = (): boolean => state().ui.toolbarPanel !== null;
const close = (): void => {
if (!hasOpenPanel()) return;
private get state(): ChatState {
return this.host.stateStore.getState();
}
dispatch({ type: "ui/panel-set", panel: null });
host.archiveConfirm.set(null);
host.scheduleRender();
};
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
return {
archiveConfirmId(): string | null {
return host.archiveConfirm.get();
},
archiveConfirmId(): string | null {
return this.host.archiveConfirm.get();
}
onArchiveConfirmChange(listener: () => void): () => void {
return host.archiveConfirm.subscribe(listener);
},
onArchiveConfirmChange(listener: () => void): () => void {
return this.host.archiveConfirm.subscribe(listener);
}
toggleHistory(): void {
dispatch({ type: "ui/panel-set", panel: "history", toggle: true });
host.scheduleRender();
},
toggleHistory(): void {
this.dispatch({ type: "ui/panel-set", panel: "history", toggle: true });
this.host.scheduleRender();
}
toggleChatActions(): void {
dispatch({ type: "ui/panel-set", panel: "chat-actions", toggle: true });
host.scheduleRender();
},
toggleChatActions(): void {
this.dispatch({ type: "ui/panel-set", panel: "chat-actions", toggle: true });
this.host.scheduleRender();
}
closeToolbarPanels(): void {
close();
},
closeToolbarPanels(): void {
this.close();
}
toggleStatus(): void {
dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true });
host.scheduleRender();
},
toggleStatus(): void {
this.dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true });
this.host.scheduleRender();
}
closeForThreadSelection(): void {
host.archiveConfirm.set(null);
},
closeForThreadSelection(): void {
this.host.archiveConfirm.set(null);
}
startArchive(threadId: string): void {
host.archiveConfirm.set(threadId);
},
startArchive(threadId: string): void {
this.host.archiveConfirm.set(threadId);
}
async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
if (host.archiveConfirm.get() === threadId) host.archiveConfirm.set(null);
await host.threadActions.archiveThread(threadId, saveMarkdown);
host.scheduleRender();
},
async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
if (this.host.archiveConfirm.get() === threadId) this.host.archiveConfirm.set(null);
await this.host.threadActions.archiveThread(threadId, saveMarkdown);
this.host.scheduleRender();
}
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void {
if (!hasOpenPanel()) return;
closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void {
if (!this.hasOpenPanel()) return;
const target = context.target;
if (isToolbarElement(target, context.viewWindow)) {
const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel");
if (insideToolbarPanel && context.contains(insideToolbarPanel)) {
if (this.host.archiveConfirm.get() && !target.closest(".codex-panel__archive-confirm")) {
this.host.archiveConfirm.set(null);
const target = context.target;
if (isToolbarElement(target, context.viewWindow)) {
const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel");
if (insideToolbarPanel && context.contains(insideToolbarPanel)) {
if (host.archiveConfirm.get() && !target.closest(".codex-panel__archive-confirm")) {
host.archiveConfirm.set(null);
}
return;
}
return;
}
}
if (this.host.archiveConfirm.get()) {
this.host.archiveConfirm.set(null);
}
if (host.archiveConfirm.get()) {
host.archiveConfirm.set(null);
}
if (context.renameEditing) return;
if (context.renameEditing) return;
this.close();
}
private hasOpenPanel(): boolean {
return this.state.ui.toolbarPanel !== null;
}
private close(): void {
if (!this.hasOpenPanel()) return;
this.dispatch({ type: "ui/panel-set", panel: null });
this.host.archiveConfirm.set(null);
this.host.scheduleRender();
}
close();
},
};
}
function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element {

View file

@ -1,32 +0,0 @@
import type { ComponentChild as UiNode } from "preact";
import type { ChatStateStore } from "../state/reducer";
import { renderChatPanelShell } from "../ui/shell";
export interface ChatShellRenderPort {
render(root: HTMLElement): void;
}
export function createChatShellRenderPort(
stateStore: ChatStateStore,
options: {
showToolbar: () => boolean;
toolbarNode: () => UiNode;
goalNode: () => UiNode;
messageStreamNode: () => UiNode;
composerNode: () => UiNode;
},
): ChatShellRenderPort {
return {
render(root) {
renderChatPanelShell(root, {
stateStore,
showToolbar: options.showToolbar(),
toolbarNode: options.toolbarNode,
goalNode: options.goalNode,
messageStreamNode: options.messageStreamNode,
composerNode: options.composerNode,
});
},
};
}

View file

@ -3,55 +3,67 @@ import type { EventRef } from "obsidian";
import { unmountChatPanelShell } from "../ui/shell";
export interface ChatViewLifecycleHost {
setOpened: (opened: boolean) => void;
setClosing: (closing: boolean) => void;
registerEvent: (eventRef: EventRef) => void;
registerComposerNoteIndexInvalidation: (register: (eventRef: EventRef) => void) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
applyCachedSharedAppServerState: () => void;
render: () => void;
scheduleDeferredAppServerWarmup: () => void;
scheduleDeferredRestoredThreadHydration: () => void;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredTasks: () => void;
panelRoot: () => HTMLElement | null;
disposeMessages: () => void;
disposeComposer: () => void;
disconnect: () => void;
clearClient: () => void;
refreshLiveState: () => void;
deferRefreshLiveState: () => void;
lifecycle: {
setOpened: (opened: boolean) => void;
setClosing: (closing: boolean) => void;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredTasks: () => void;
scheduleDeferredAppServerWarmup: () => void;
scheduleDeferredRestoredThreadHydration: () => void;
};
events: {
registerEvent: (eventRef: EventRef) => void;
registerComposerNoteIndexInvalidation: (register: (eventRef: EventRef) => void) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
};
render: {
panelRoot: () => HTMLElement | null;
now: () => void;
};
sharedState: {
applyCachedAppServerState: () => void;
};
resources: {
disposeMessages: () => void;
disposeComposer: () => void;
disconnect: () => void;
clearClient: () => void;
};
liveState: {
refresh: () => void;
deferRefresh: () => void;
};
}
export function openChatView(host: ChatViewLifecycleHost): void {
host.setOpened(true);
host.setClosing(false);
host.registerComposerNoteIndexInvalidation((eventRef) => {
host.registerEvent(eventRef);
host.lifecycle.setOpened(true);
host.lifecycle.setClosing(false);
host.events.registerComposerNoteIndexInvalidation((eventRef) => {
host.events.registerEvent(eventRef);
});
host.registerPointerDown((event) => {
host.closeToolbarPanelOnOutsidePointer(event);
host.events.registerPointerDown((event) => {
host.events.closeToolbarPanelOnOutsidePointer(event);
});
host.applyCachedSharedAppServerState();
host.render();
host.scheduleDeferredAppServerWarmup();
host.scheduleDeferredRestoredThreadHydration();
host.sharedState.applyCachedAppServerState();
host.render.now();
host.lifecycle.scheduleDeferredAppServerWarmup();
host.lifecycle.scheduleDeferredRestoredThreadHydration();
}
export function closeChatView(host: ChatViewLifecycleHost): void {
host.setOpened(false);
host.setClosing(true);
host.invalidateConnectionWork();
host.invalidateResumeWork();
host.clearDeferredTasks();
const panelRoot = host.panelRoot();
host.disposeMessages();
host.disposeComposer();
host.lifecycle.setOpened(false);
host.lifecycle.setClosing(true);
host.lifecycle.invalidateConnectionWork();
host.lifecycle.invalidateResumeWork();
host.lifecycle.clearDeferredTasks();
const panelRoot = host.render.panelRoot();
host.resources.disposeMessages();
host.resources.disposeComposer();
unmountChatPanelShell(panelRoot);
host.disconnect();
host.clearClient();
host.refreshLiveState();
host.deferRefreshLiveState();
host.resources.disconnect();
host.resources.clearClient();
host.liveState.refresh();
host.liveState.deferRefresh();
}

View file

@ -1,18 +0,0 @@
import type { ChatShellRenderPort } from "./shell-render";
export interface ChatViewRenderControllerHost {
shell: ChatShellRenderPort;
panelRoot: () => HTMLElement | null;
clearScheduledRender: () => void;
}
export class ChatViewRenderController {
constructor(private readonly host: ChatViewRenderControllerHost) {}
render(): void {
this.host.clearScheduledRender();
const root = this.host.panelRoot();
if (!root) return;
this.host.shell.render(root);
}
}

View file

@ -23,6 +23,15 @@ export interface ChatThreadActionsHost {
refreshSharedThreadListFromOpenSurface: () => void;
}
export interface ChatThreadActions {
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<boolean>;
rollbackThread: (threadId: string) => Promise<void>;
}
export function threadActionState(host: ChatThreadActionsHost): ChatState {
return host.stateStore.getState();
}

View file

@ -1,28 +0,0 @@
import { archiveThread } from "./archive-actions";
import { compactThread } from "./compact-actions";
import type { ChatThreadActionsHost } from "./action-context";
import { forkThread, forkThreadFromTurn } from "./fork-actions";
import { renameThread } from "./rename-actions";
import { rollbackThread } from "./rollback-actions";
export type { ChatThreadActionsHost } from "./action-context";
export interface ChatThreadActions {
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<boolean>;
rollbackThread: (threadId: string) => Promise<void>;
}
export function createChatThreadActions(host: ChatThreadActionsHost): ChatThreadActions {
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

@ -1,37 +1,28 @@
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 { createChatThreadActions } from "./actions";
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 { ChatStateStore } from "../state/reducer";
import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { CodexPanelSettings } from "../../../settings/model";
import type { ChatControllerCompositionPorts } from "../composition-ports";
interface ThreadControllerGroupPorts {
obsidian: {
archiveAdapter: () => ArchiveExportAdapter;
};
plugin: {
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string | null) => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
refreshSharedThreadListFromOpenSurface: () => void;
settings: CodexPanelSettings;
vaultPath: string;
};
state: {
stateStore: ChatStateStore;
};
type ThreadControllerGroupPorts = Pick<
ChatControllerCompositionPorts,
"obsidian" | "plugin" | "state" | "lifecycle" | "thread" | "liveState"
> & {
client: {
getClient: () => AppServerClient | null;
getClient: ChatControllerCompositionPorts["client"]["getClient"];
ensureConnected: () => Promise<void>;
};
lifecycle: {
@ -39,7 +30,6 @@ interface ThreadControllerGroupPorts {
resumeWork: ChatResumeWorkTracker;
getOpened: () => boolean;
getClosing: () => boolean;
clearDeferredRestoredThreadHydration: () => void;
};
thread: {
selectThread: (threadId: string) => Promise<void>;
@ -51,20 +41,14 @@ interface ThreadControllerGroupPorts {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
liveState: {
refresh: () => void;
};
scroll: {
preservePosition: () => void;
forceBottom: () => void;
};
scroll: Pick<ChatControllerCompositionPorts["scroll"], "preservePosition" | "forceBottom">;
render: {
now: () => void;
};
composer: {
setText: (text: string) => void;
};
}
};
export function createThreadControllerGroup(
context: ThreadControllerGroupPorts,
@ -111,7 +95,7 @@ export function createThreadControllerGroup(
resumeWork.invalidate();
history.invalidate();
};
const actions = createChatThreadActions({
const threadActionHost: ChatThreadActionsHost = {
stateStore,
vaultPath: plugin.vaultPath,
settings: () => plugin.settings,
@ -133,7 +117,15 @@ export function createThreadControllerGroup(
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,
@ -165,7 +157,9 @@ export function createThreadControllerGroup(
ensureConnected: client.ensureConnected,
closing: lifecycle.getClosing,
resetThreadTurnPresence,
clearDeferredRestoredThreadHydration: lifecycle.clearDeferredRestoredThreadHydration,
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
addSystemMessage: status.addSystemMessage,
render: render.now,
@ -181,7 +175,9 @@ export function createThreadControllerGroup(
stateStore,
restoration,
invalidateResumeWork,
clearDeferredRestoredThreadHydration: lifecycle.clearDeferredRestoredThreadHydration,
clearDeferredRestoredThreadHydration: () => {
restoration.clearHydration();
},
resetThreadTurnPresence,
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
refreshTabHeader: thread.refreshTabHeader,
@ -207,20 +203,14 @@ function requireThreadController<T>(controller: T | null, name: string): T {
return controller;
}
interface ThreadSelectionActionGroupPorts {
plugin: {
focusThreadInOpenView: (threadId: string) => Promise<boolean>;
};
state: {
stateStore: ChatStateStore;
};
type ThreadSelectionActionGroupPorts = Pick<ChatControllerCompositionPorts, "plugin" | "state"> & {
status: {
addSystemMessage: (text: string) => void;
};
thread: {
resumeThread: (threadId: string) => Promise<void>;
};
}
};
export function createThreadSelectionActionGroup(
context: ThreadSelectionActionGroupPorts,

View file

@ -1,23 +0,0 @@
import type { MessageStreamScrollIntent } from "./virtualizer";
export class ChatMessageScrollIntentController {
private nextIntent: MessageStreamScrollIntent = "auto";
consumeIntent(): MessageStreamScrollIntent {
const value = this.nextIntent;
this.nextIntent = "auto";
return value;
}
forceBottom(): void {
this.nextIntent = "force-bottom";
}
followBottom(): void {
this.nextIntent = "follow-bottom";
}
preservePosition(): void {
this.nextIntent = "preserve";
}
}

View file

@ -0,0 +1,32 @@
import type { MessageStreamScrollIntent } from "./virtualizer";
export interface ChatMessageScrollIntentState {
consumeIntent(): MessageStreamScrollIntent;
forceBottom(): void;
followBottom(): void;
preservePosition(): void;
}
export function createChatMessageScrollIntentState(): ChatMessageScrollIntentState {
let nextIntent: MessageStreamScrollIntent = "auto";
return {
consumeIntent(): MessageStreamScrollIntent {
const value = nextIntent;
nextIntent = "auto";
return value;
},
forceBottom(): void {
nextIntent = "force-bottom";
},
followBottom(): void {
nextIntent = "follow-bottom";
},
preservePosition(): void {
nextIntent = "preserve";
},
};
}

View file

@ -3,12 +3,11 @@ import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
import type { AppServerClient } from "../../app-server/connection/client";
import { VIEW_TYPE_CODEX_PANEL } from "../../constants";
import type { DisplayDetailSection, DisplayItem } from "./display/types";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import type { ModelMetadata } from "../../domain/catalog/metadata";
import type { Thread } from "../../domain/threads/model";
import type { RuntimeSnapshot } from "./runtime/snapshot";
import { collaborationModeLabel as formatCollaborationModeLabel } from "./runtime/pending-settings";
import { chatTurnBusy, createChatStateStore, type ChatState, type ChatAction } from "./state/reducer";
import { chatTurnBusy, createChatStateStore, type ChatAction, type ChatState } from "./state/reducer";
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
import type { SharedServerMetadata } from "../../domain/server/metadata";
import type { CodexChatHost } from "./chat-host";
@ -22,23 +21,16 @@ import { runtimeSnapshotForChatState } from "./runtime/snapshot";
import { codexPanelDisplayTitle, getThreadTitle } from "../../domain/threads/model";
import { connectionDiagnosticsModel } from "./panel/regions/toolbar";
import { openPanelTurnLifecycle } from "./panel/snapshot";
import { ChatConnectionWorkTracker, ChatResumeWorkTracker, ChatViewDeferredTasks } from "./lifecycle";
import { ChatMessageScrollIntentController } from "./ui/message-stream/scroll-intent-controller";
import { ChatConnectionWorkTracker, ChatResumeWorkTracker, createChatViewDeferredTasks, type ChatViewDeferredTasks } from "./lifecycle";
import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState } from "./ui/message-stream/scroll-intent-state";
import type { ChatControllerCompositionPorts } from "./composition-ports";
import { createChatViewControllers, type ChatViewControllers } from "./composition";
import type { ChatPanelComposerPorts, ChatPanelGoalPorts, ChatPanelMessageStreamPorts, ChatPanelToolbarPorts } from "./panel/regions/ports";
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder } from "./panel/regions/composer";
import { chatPanelMessageStreamPendingRequestsSignature } from "./panel/regions/message-stream";
import {
chatPanelComposerRegionNode,
chatPanelGoalRegionNode,
chatPanelMessageStreamRegionNode,
chatPanelToolbarRegionNode,
} from "./panel/regions/render";
type ChatPanelToolbarActions = ChatPanelToolbarPorts["actions"]["toolbar"];
type ChatPanelToolbarState = ChatPanelToolbarPorts["view"]["toolbar"];
type ChatPanelGoalActions = ChatPanelGoalPorts["actions"]["goal"];
import type { ChatPanelRegionPorts } from "./panel/regions/ports";
import { createChatPanelRegionPorts } from "./panel/regions/composition";
import { chatPanelComposerMetaViewModel, chatPanelComposerPlaceholder, chatPanelComposerRegionNode } from "./panel/regions/composer";
import { chatPanelGoalRegionNode } from "./panel/regions/goal";
import { chatPanelMessageStreamPendingRequestsSignature, chatPanelMessageStreamRegionNode } from "./panel/regions/message-stream";
import { chatPanelToolbarRegionNode } from "./panel/regions/toolbar";
export class CodexChatView extends ItemView {
private client: AppServerClient | null = null;
@ -46,11 +38,8 @@ export class CodexChatView extends ItemView {
private readonly chatState = createChatStateStore();
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
private readonly deferredTasks: ChatViewDeferredTasks;
private readonly messageScrollIntent: ChatMessageScrollIntentController;
private readonly toolbarPorts: ChatPanelToolbarPorts;
private readonly goalPorts: ChatPanelGoalPorts;
private readonly messageStreamPorts: ChatPanelMessageStreamPorts;
private readonly composerPorts: ChatPanelComposerPorts;
private readonly messageScrollIntent: ChatMessageScrollIntentState;
private readonly panelPorts: ChatPanelRegionPorts;
private readonly connectionWork = new ChatConnectionWorkTracker();
private readonly resumeWork: ChatResumeWorkTracker;
private opened = false;
@ -61,18 +50,29 @@ export class CodexChatView extends ItemView {
private readonly plugin: CodexChatHost,
) {
super(leaf);
this.deferredTasks = new ChatViewDeferredTasks(() => this.containerEl.win);
this.deferredTasks = createChatViewDeferredTasks(() => this.containerEl.win);
this.resumeWork = new ChatResumeWorkTracker();
this.messageScrollIntent = new ChatMessageScrollIntentController();
this.messageScrollIntent = createChatMessageScrollIntentState();
this.controllers = createChatViewControllers(this.createControllerPorts());
const panelPorts = this.createPanelRegionPorts(this.controllers);
this.toolbarPorts = panelPorts.toolbar;
this.goalPorts = panelPorts.goal;
this.messageStreamPorts = panelPorts.messageStream;
this.composerPorts = panelPorts.composer;
this.panelPorts = createChatPanelRegionPorts(
{
settings: this.plugin.settings,
vaultPath: this.plugin.vaultPath,
stateStore: this.chatState,
runtimeSnapshot: () => this.runtimeSnapshot(),
restoredThreadPlaceholder: () => this.restoredThreadPlaceholder(),
messageStreamNode: () => this.controllers.render.messageStream.renderNode(),
startNewThread: () => this.startNewThread(),
},
this.controllers,
);
}
private createControllerPorts(): ChatControllerCompositionPorts {
const refreshThreadsViewLiveState = () => {
this.plugin.refreshThreadsViewLiveState();
};
return {
obsidian: {
app: this.app,
@ -86,39 +86,9 @@ export class CodexChatView extends ItemView {
},
archiveAdapter: () => this.app.vault.adapter,
},
plugin: {
settings: this.plugin.settings,
vaultPath: this.plugin.vaultPath,
openThreadInNewView: (threadId) => this.plugin.openThreadInNewView(threadId),
focusThreadInOpenView: (threadId) => this.plugin.focusThreadInOpenView(threadId),
openTurnDiff: (state) => this.plugin.openTurnDiff(state),
notifyThreadArchived: (threadId) => {
this.plugin.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
this.plugin.notifyThreadRenamed(threadId, name);
},
refreshThreadsViewLiveState: () => {
this.plugin.refreshThreadsViewLiveState();
},
refreshSharedThreadListFromOpenSurface: () => {
this.plugin.refreshSharedThreadListFromOpenSurface();
},
applyThreadListSnapshot: (threads) => {
this.plugin.applyThreadListSnapshot(threads);
},
publishAppServerMetadata: (metadata) => {
this.plugin.publishAppServerMetadata(metadata);
},
publishAppServerIdentity: (userAgent) => {
this.plugin.publishAppServerIdentity(userAgent);
},
cachedThreadList: () => this.plugin.cachedThreadList(),
cachedAppServerMetadata: () => this.plugin.cachedAppServerMetadata(),
},
plugin: this.plugin,
state: {
stateStore: this.chatState,
getState: () => this.state,
systemItem: (text) => this.systemItem(text),
structuredSystemItem: (text, details) => this.structuredSystemItem(text, details),
},
@ -144,30 +114,13 @@ export class CodexChatView extends ItemView {
setClosing: (closing) => {
this.closing = closing;
},
invalidateConnectionWork: () => {
this.connectionWork.invalidate();
},
scheduleDeferredDiagnostics: () => {
this.scheduleDeferredDiagnostics();
},
clearDeferredDiagnostics: () => {
this.clearDeferredDiagnostics();
},
scheduleDeferredRestoredThreadHydration: () => {
this.scheduleDeferredRestoredThreadHydration();
},
clearDeferredRestoredThreadHydration: () => {
this.clearDeferredRestoredThreadHydration();
},
scheduleDeferredAppServerWarmup: () => {
this.scheduleDeferredAppServerWarmup();
},
refreshDeferredDiagnostics: () => this.refreshDeferredDiagnostics(),
},
render: {
panelRoot: () => this.panelRoot(),
toolbarNode: () => chatPanelToolbarRegionNode(this.toolbarPorts),
goalNode: () => chatPanelGoalRegionNode(this.goalPorts),
messageStreamNode: () => chatPanelMessageStreamRegionNode(this.messageStreamPorts),
toolbarNode: () => chatPanelToolbarRegionNode(this.panelPorts.toolbar),
goalNode: () => chatPanelGoalRegionNode(this.panelPorts.goal),
messageStreamNode: () => chatPanelMessageStreamRegionNode(this.panelPorts.messageStream),
composerNode: () => chatPanelComposerRegionNode(() => this.controllers.composer.controller.renderNode()),
closeToolbarPanelOnOutsidePointer: (event) => {
this.closeToolbarPanelOnOutsidePointer(event);
@ -176,15 +129,12 @@ export class CodexChatView extends ItemView {
this.scheduleRender();
},
},
messageStream: {
pendingRequestsSignature: () => chatPanelMessageStreamPendingRequestsSignature(this.messageStreamPorts),
},
composerView: {
composerPlaceholder: () => chatPanelComposerPlaceholder(this.composerPorts),
composerMetaViewModel: () => chatPanelComposerMetaViewModel(this.composerPorts),
surface: {
pendingRequestsSignature: () => chatPanelMessageStreamPendingRequestsSignature(this.panelPorts.messageStream),
composerPlaceholder: () => chatPanelComposerPlaceholder(this.panelPorts.composer),
composerMetaViewModel: () => chatPanelComposerMetaViewModel(this.panelPorts.composer),
},
runtime: {
runtimeSnapshotForState: (state) => this.runtimeSnapshotForState(state),
collaborationModeLabel: () => this.collaborationModeLabel(),
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
modelStatusLines: () => this.modelStatusLines(),
@ -203,12 +153,10 @@ export class CodexChatView extends ItemView {
},
},
liveState: {
refresh: () => {
this.plugin.refreshThreadsViewLiveState();
},
refresh: refreshThreadsViewLiveState,
deferRefresh: () => {
this.containerEl.win.setTimeout(() => {
this.plugin.refreshThreadsViewLiveState();
refreshThreadsViewLiveState();
}, 0);
},
},
@ -231,181 +179,10 @@ export class CodexChatView extends ItemView {
};
}
private createPanelRegionPorts(controllers: ChatViewControllers): {
toolbar: ChatPanelToolbarPorts;
goal: ChatPanelGoalPorts;
messageStream: ChatPanelMessageStreamPorts;
composer: ChatPanelComposerPorts;
} {
const state = {
chat: () => this.state,
};
return {
toolbar: {
state: {
...state,
connected: () => controllers.connection.manager.isConnected(),
turnBusy: () => this.turnBusy,
},
settings: {
vaultPath: () => this.plugin.vaultPath,
configuredCommand: () => this.plugin.settings.codexPath,
archiveExportEnabled: () => this.plugin.settings.archiveExportEnabled,
},
runtime: {
snapshot: () => this.runtimeSnapshot(),
},
view: {
toolbar: this.createToolbarPanelState(controllers),
},
actions: {
toolbar: this.createToolbarPanelActions(controllers),
},
},
goal: {
state,
settings: {
sendShortcut: () => this.plugin.settings.sendShortcut,
},
actions: {
goal: this.createGoalPanelActions(controllers),
},
},
messageStream: {
state,
render: {
node: () => this.controllers.render.messageStream.renderNode(),
},
},
composer: {
state,
thread: {
restoredPlaceholder: () => this.restoredThreadPlaceholder(),
},
runtime: {
snapshot: () => this.runtimeSnapshot(),
requestModel: (model) => this.requestModelFromUi(model),
requestReasoningEffort: (effort) => this.requestReasoningEffortFromUi(effort),
resetReasoningEffortToConfig: () => this.resetReasoningEffortToConfigFromUi(),
},
},
};
}
private createToolbarPanelState(controllers: ChatViewControllers): ChatPanelToolbarState {
return {
archiveConfirmId: () => controllers.toolbar.panels.archiveConfirmId(),
archiveConfirmSubscribe: (listener) => controllers.toolbar.panels.onArchiveConfirmChange(listener),
renameState: (threadId) => controllers.thread.rename.editState(threadId),
renameSubscribe: (listener) => controllers.thread.rename.subscribe(listener),
};
}
private createToolbarPanelActions(controllers: ChatViewControllers): ChatPanelToolbarActions {
return {
startNewThread: () => {
void this.startNewThread();
},
toggleChatActions: () => {
controllers.toolbar.panels.toggleChatActions();
},
compactConversation: () => {
void this.compactConversation();
},
setGoal: () => {
this.setGoalEditingOpen(true, { closeToolbarPanel: true });
},
toggleHistory: () => {
controllers.toolbar.panels.toggleHistory();
},
toggleStatusPanel: () => {
controllers.toolbar.panels.toggleStatus();
},
connect: () => {
void controllers.connection.reconnect.reconnectPanel();
},
refreshStatus: () => {
void controllers.connection.controller.refreshStatusPanel();
},
resumeThread: (threadId) => {
void controllers.thread.selection.selectThreadFromToolbar(threadId);
},
startArchiveThread: (threadId) => {
controllers.toolbar.panels.startArchive(threadId);
},
archiveThread: (threadId, saveMarkdown) => {
void controllers.toolbar.panels.archiveThread(threadId, saveMarkdown);
},
startRenameThread: (threadId) => {
controllers.thread.rename.start(threadId);
},
updateRenameDraft: (threadId, value) => {
controllers.thread.rename.updateDraft(threadId, value);
},
saveRenameThread: (threadId, value) => {
void controllers.thread.rename.save(threadId, value);
},
cancelRenameThread: (threadId) => {
controllers.thread.rename.cancel(threadId);
},
autoNameThread: (threadId) => {
void controllers.thread.rename.autoNameDraft(threadId);
},
};
}
private createGoalPanelActions(controllers: ChatViewControllers): ChatPanelGoalActions {
return {
saveObjective: (objective, tokenBudget) => this.saveGoalObjective(objective, tokenBudget),
setStatus: (threadId, status) => controllers.runtime.goals.setStatus(threadId, status),
clear: (threadId) => controllers.runtime.goals.clear(threadId),
setEditingOpen: (open) => {
this.setGoalEditingOpen(open);
},
};
}
private async compactConversation(): Promise<void> {
const threadId = this.state.activeThread.id;
if (!threadId) {
this.controllers.inbound.controller.addSystemMessage("No active thread to compact.");
this.controllers.render.controller.render();
return;
}
await this.controllers.thread.actions.compactThread(threadId);
}
private setGoalEditingOpen(open: boolean, { closeToolbarPanel = false }: { closeToolbarPanel?: boolean } = {}): void {
if (closeToolbarPanel) this.dispatch({ type: "ui/panel-set", panel: null });
this.dispatch({ type: "ui/detail-open-set", key: "goal:editor", open });
this.controllers.render.controller.render();
}
private async saveGoalObjective(objective: string, tokenBudget: number | null): Promise<void> {
let threadId = this.state.activeThread.id;
if (!threadId) {
try {
await this.controllers.connection.controller.ensureConnected();
const response = await this.controllers.serverActions.threads.startThread(objective, { syncGoal: false });
threadId = response?.threadId ?? null;
} catch (error) {
this.controllers.inbound.controller.addSystemMessage(error instanceof Error ? error.message : String(error));
this.controllers.render.controller.render();
return;
}
}
if (!threadId) return;
void this.controllers.runtime.goals.setObjective(threadId, objective, tokenBudget);
}
private get state(): ChatState {
return this.chatState.getState();
}
private get turnBusy(): boolean {
return chatTurnBusy(this.state);
}
private dispatch(action: ChatAction): void {
this.chatState.dispatch(action);
}
@ -448,7 +225,7 @@ export class CodexChatView extends ItemView {
}
refreshSettings(): void {
this.controllers.render.controller.render();
this.controllers.render.now();
}
refreshSharedThreadList(): Promise<void> {
@ -458,17 +235,17 @@ export class CodexChatView extends ItemView {
applyThreadListSnapshot(threads: readonly Thread[]): void {
this.controllers.serverActions.threads.applyThreadList(threads);
this.refreshTabHeader();
this.controllers.render.controller.render();
this.controllers.render.now();
}
applyAppServerMetadataSnapshot(metadata: SharedServerMetadata): void {
this.controllers.serverActions.metadata.applyAppServerMetadata(metadata);
this.controllers.render.controller.render();
this.controllers.render.now();
}
applyAvailableModelsSnapshot(models: readonly ModelMetadata[]): void {
this.dispatch({ type: "connection/metadata-applied", availableModels: models });
this.controllers.render.controller.render();
this.controllers.render.now();
}
openPanelSnapshot(): OpenCodexPanelSnapshot {
@ -518,7 +295,7 @@ export class CodexChatView extends ItemView {
setComposerText(text: string): void {
this.controllers.composer.controller.setDraft(text, { focus: true });
this.controllers.render.controller.render();
this.controllers.render.now();
}
async connect(): Promise<void> {
@ -526,12 +303,12 @@ export class CodexChatView extends ItemView {
}
async startNewThread(): Promise<void> {
if (this.turnBusy) return;
if (chatTurnBusy(this.state)) return;
this.controllers.thread.identity.clearActiveThreadContext();
this.chatState.dispatch({ type: "ui/panel-set", panel: null });
this.dispatch({ type: "connection/status-set", status: "New chat." });
this.controllers.render.controller.render();
this.controllers.render.now();
this.focusComposer();
}
@ -561,18 +338,6 @@ export class CodexChatView extends ItemView {
void this.app.workspace.requestSaveLayout();
}
private async requestModelFromUi(model: string): Promise<void> {
await this.controllers.runtime.settings.requestModelFromUi(model);
}
private async requestReasoningEffortFromUi(effort: ReasoningEffort): Promise<void> {
await this.controllers.runtime.settings.requestReasoningEffortFromUi(effort);
}
private async resetReasoningEffortToConfigFromUi(): Promise<void> {
await this.controllers.runtime.settings.resetReasoningEffortToConfigFromUi();
}
private async ensureRestoredThreadLoaded(): Promise<boolean> {
return this.controllers.thread.restoration.ensureLoaded();
}
@ -581,18 +346,6 @@ export class CodexChatView extends ItemView {
return this.controllers.thread.restoration.isPending(threadId);
}
private scheduleDeferredRestoredThreadHydration(): void {
this.controllers.thread.restoration.scheduleHydration();
}
private clearDeferredRestoredThreadHydration(): void {
this.controllers.thread.restoration.clearHydration();
}
private scheduleDeferredAppServerWarmup(): void {
this.controllers.connection.scheduleWarmup();
}
private activeThreadTitle(): string | null {
const threadId = this.state.activeThread.id;
if (!threadId) return null;
@ -619,24 +372,14 @@ export class CodexChatView extends ItemView {
private scheduleRender(): void {
this.deferredTasks.scheduleRender(() => {
this.controllers.render.controller.render();
this.controllers.render.now();
});
}
private scheduleDeferredDiagnostics(): void {
this.deferredTasks.scheduleDiagnostics(() => {
void this.refreshDeferredDiagnostics();
});
}
private clearDeferredDiagnostics(): void {
this.deferredTasks.clearDiagnostics();
}
private async refreshDeferredDiagnostics(): Promise<void> {
if (!this.controllers.connection.manager.isConnected()) return;
await this.controllers.serverActions.diagnostics.refreshPublishedDiagnosticProbes({ cachedAppServerMetadata: true });
this.controllers.render.controller.render();
this.controllers.render.now();
}
private panelRoot(): HTMLElement | null {
@ -688,10 +431,6 @@ export class CodexChatView extends ItemView {
}
private runtimeSnapshot(): RuntimeSnapshot {
return this.runtimeSnapshotForState(this.state);
}
private runtimeSnapshotForState(state: ChatState): RuntimeSnapshot {
return runtimeSnapshotForChatState(state);
return runtimeSnapshotForChatState(this.state);
}
}

View file

@ -17,35 +17,30 @@ export type ThreadsViewConnectionLifecycleState = ConnectionWorkLifecycleState;
export type ActiveThreadsViewConnection = ActiveConnectionWork;
export type ThreadsViewConnectionLifecycleEvent = ConnectionWorkLifecycleEvent;
export class ThreadsViewDeferredTasks {
private readonly renderTask: DeferredTask;
private readonly refreshTask: DeferredTask;
export interface ThreadsViewDeferredTasks {
scheduleRender(callback: () => void): void;
scheduleRefresh(callback: () => void): void;
clearAll(): void;
}
constructor(getWindow: () => DeferredTaskWindow) {
this.renderTask = new DeferredTask(getWindow, 0);
this.refreshTask = new DeferredTask(getWindow, 250);
}
export function createThreadsViewDeferredTasks(getWindow: () => DeferredTaskWindow): ThreadsViewDeferredTasks {
const renderTask = new DeferredTask(getWindow, 0);
const refreshTask = new DeferredTask(getWindow, 250);
scheduleRender(callback: () => void): void {
this.renderTask.schedule(callback);
}
return {
scheduleRender(callback): void {
renderTask.schedule(callback);
},
scheduleRefresh(callback: () => void): void {
this.refreshTask.schedule(callback);
}
scheduleRefresh(callback): void {
refreshTask.schedule(callback);
},
clearAll(): void {
this.clearRender();
this.clearRefresh();
}
private clearRender(): void {
this.renderTask.clear();
}
private clearRefresh(): void {
this.refreshTask.clear();
}
clearAll(): void {
renderTask.clear();
refreshTask.clear();
},
};
}
export function transitionThreadsViewRefreshLifecycle(

View file

@ -22,11 +22,12 @@ import {
type ThreadsRenameState,
} from "./state";
import {
ThreadsViewDeferredTasks,
createThreadsViewDeferredTasks,
transitionThreadsViewConnectionLifecycle,
transitionThreadsViewRefreshLifecycle,
type ActiveThreadsViewConnection,
type ActiveThreadsViewRefresh,
type ThreadsViewDeferredTasks,
type ThreadsViewConnectionLifecycleState,
type ThreadsViewRefreshLifecycleState,
} from "./view-lifecycle";
@ -67,7 +68,7 @@ export class CodexThreadsView extends ItemView {
private readonly plugin: CodexThreadsHost,
) {
super(leaf);
this.deferredTasks = new ThreadsViewDeferredTasks(() => this.containerEl.win);
this.deferredTasks = createThreadsViewDeferredTasks(() => this.containerEl.win);
this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath);
this.connection.setHandlers({
onNotification: () => {

View file

@ -13,7 +13,9 @@ import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormali
import { CodexPanelSettingTab, type CodexPanelSettingTabHost } from "./settings/tab";
import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./features/chat/turn-diff/model";
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
import { ThreadSurfaceCoordinator } from "./workspace/thread-surface-coordinator";
import { createThreadSurfaceActions } from "./workspace/thread-surface-actions";
import type { SharedServerMetadata } from "./domain/server/metadata";
import type { Thread } from "./domain/threads/model";
export default class CodexPanelPlugin extends Plugin {
settings: CodexPanelSettings = DEFAULT_SETTINGS;
@ -26,7 +28,7 @@ export default class CodexPanelPlugin extends Plugin {
this.threadSurfaces.refreshThreadsViewLiveState();
},
});
private readonly threadSurfaces = new ThreadSurfaceCoordinator({
private readonly threadSurfaces = createThreadSurfaceActions({
app: this.app,
panels: this.panels,
refreshThreadSurfaces: () => {
@ -141,6 +143,26 @@ export default class CodexPanelPlugin extends Plugin {
this.appServerUserAgent = userAgent;
}
private applyThreadListSnapshot(threads: readonly Thread[]): void {
this.sharedAppServerCache.applyThreadListSnapshot(this.sharedAppServerCacheContext(), threads);
this.threadSurfaces.applyThreadListSnapshot(threads);
}
private refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]> {
return this.sharedAppServerCache.refreshThreadList(this.sharedAppServerCacheContext(), fetchThreads, (threads) => {
this.threadSurfaces.applyThreadListSnapshot(threads);
});
}
private cachedThreadList(): readonly Thread[] | null {
return this.sharedAppServerCache.cachedThreadList(this.sharedAppServerCacheContext());
}
private publishAppServerMetadata(metadata: SharedServerMetadata): void {
this.sharedAppServerCache.applyAppServerMetadataSnapshot(this.sharedAppServerCacheContext(), metadata);
this.threadSurfaces.publishAppServerMetadata(metadata);
}
private chatHost(): CodexChatHost {
return {
settings: this.settings,
@ -161,17 +183,12 @@ export default class CodexPanelPlugin extends Plugin {
this.threadSurfaces.refreshSharedThreadListFromOpenSurface();
},
applyThreadListSnapshot: (threads) => {
this.sharedAppServerCache.applyThreadListSnapshot(this.sharedAppServerCacheContext(), threads);
this.threadSurfaces.applyThreadListSnapshot(threads);
this.applyThreadListSnapshot(threads);
},
refreshThreadList: (fetchThreads) =>
this.sharedAppServerCache.refreshThreadList(this.sharedAppServerCacheContext(), fetchThreads, (threads) => {
this.threadSurfaces.applyThreadListSnapshot(threads);
}),
cachedThreadList: () => this.sharedAppServerCache.cachedThreadList(this.sharedAppServerCacheContext()),
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
cachedThreadList: () => this.cachedThreadList(),
publishAppServerMetadata: (metadata) => {
this.sharedAppServerCache.applyAppServerMetadataSnapshot(this.sharedAppServerCacheContext(), metadata);
this.threadSurfaces.publishAppServerMetadata(metadata);
this.publishAppServerMetadata(metadata);
},
publishAppServerIdentity: (userAgent) => {
this.publishAppServerIdentity(userAgent);
@ -196,11 +213,8 @@ export default class CodexPanelPlugin extends Plugin {
publishAppServerIdentity: (userAgent) => {
this.publishAppServerIdentity(userAgent);
},
refreshThreadList: (fetchThreads) =>
this.sharedAppServerCache.refreshThreadList(this.sharedAppServerCacheContext(), fetchThreads, (threads) => {
this.threadSurfaces.applyThreadListSnapshot(threads);
}),
cachedThreadList: () => this.sharedAppServerCache.cachedThreadList(this.sharedAppServerCacheContext()),
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
cachedThreadList: () => this.cachedThreadList(),
};
}
@ -209,11 +223,8 @@ export default class CodexPanelPlugin extends Plugin {
app: this.app,
settings: this.settings,
vaultPath: this.vaultPath,
cachedThreadList: () => this.sharedAppServerCache.cachedThreadList(this.sharedAppServerCacheContext()),
refreshThreadList: (fetchThreads) =>
this.sharedAppServerCache.refreshThreadList(this.sharedAppServerCacheContext(), fetchThreads, (threads) => {
this.threadSurfaces.applyThreadListSnapshot(threads);
}),
cachedThreadList: () => this.cachedThreadList(),
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
openThreadInCurrentView: (threadId) => this.panels.openThreadInCurrentView(threadId),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
};

View file

@ -0,0 +1,96 @@
import type { App } from "obsidian";
import { VIEW_TYPE_CODEX_THREADS } from "../constants";
import { CodexThreadsView } from "../features/threads-view/view";
import type { ModelMetadata } from "../domain/catalog/metadata";
import type { Thread } from "../domain/threads/model";
import type { SharedServerMetadata } from "../domain/server/metadata";
import type { WorkspacePanelCoordinator } from "./panel-coordinator";
export interface ThreadSurfaceActionsOptions {
app: App;
panels: WorkspacePanelCoordinator;
refreshThreadSurfaces: () => void;
}
export interface ThreadSurfaceActions {
refreshOpenViews(): void;
refreshSharedThreadListFromOpenSurface(): void;
applyThreadListSnapshot(threads: readonly Thread[]): void;
publishAppServerMetadata(metadata: SharedServerMetadata): void;
publishModels(models: readonly ModelMetadata[]): void;
refreshThreadsViewLiveState(): void;
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
notifyThreadRenamed(threadId: string, name: string | null): void;
}
export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions): ThreadSurfaceActions {
const threadsViews = (): CodexThreadsView[] =>
options.app.workspace
.getLeavesOfType(VIEW_TYPE_CODEX_THREADS)
.flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : []));
return {
refreshOpenViews(): void {
for (const view of options.panels.panelViews()) {
view.refreshSettings();
}
},
refreshSharedThreadListFromOpenSurface(): void {
const chatView = options.panels.panelViews().find((view) => view.openPanelSnapshot().connected);
if (chatView) {
void chatView.refreshSharedThreadList();
return;
}
const threadsView = threadsViews().at(0);
if (threadsView) void threadsView.refresh();
},
applyThreadListSnapshot(threads: readonly Thread[]): void {
for (const view of options.panels.panelViews()) {
view.applyThreadListSnapshot(threads);
}
for (const view of threadsViews()) {
view.applyThreadListSnapshot(threads);
}
},
publishAppServerMetadata(metadata: SharedServerMetadata): void {
for (const view of options.panels.panelViews()) {
view.applyAppServerMetadataSnapshot(metadata);
}
},
publishModels(models: readonly ModelMetadata[]): void {
for (const view of options.panels.panelViews()) {
view.applyAvailableModelsSnapshot(models);
}
},
refreshThreadsViewLiveState(): void {
for (const view of threadsViews()) {
view.refreshLiveState();
}
},
notifyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void {
const leavesToClose = archiveOptions.closeOpenPanels ? options.panels.panelLeavesForThread(threadId) : [];
for (const view of options.panels.panelViews()) {
view.notifyThreadArchived(threadId);
}
for (const leaf of leavesToClose) {
leaf.detach();
}
options.refreshThreadSurfaces();
},
notifyThreadRenamed(threadId: string, name: string | null): void {
for (const view of options.panels.panelViews()) {
view.notifyThreadRenamed(threadId, name);
}
options.refreshThreadSurfaces();
},
};
}

View file

@ -1,86 +0,0 @@
import type { App } from "obsidian";
import { VIEW_TYPE_CODEX_THREADS } from "../constants";
import { CodexThreadsView } from "../features/threads-view/view";
import type { ModelMetadata } from "../domain/catalog/metadata";
import type { Thread } from "../domain/threads/model";
import type { SharedServerMetadata } from "../domain/server/metadata";
import type { WorkspacePanelCoordinator } from "./panel-coordinator";
export interface ThreadSurfaceCoordinatorOptions {
app: App;
panels: WorkspacePanelCoordinator;
refreshThreadSurfaces: () => void;
}
export class ThreadSurfaceCoordinator {
constructor(private readonly options: ThreadSurfaceCoordinatorOptions) {}
refreshOpenViews(): void {
for (const view of this.options.panels.panelViews()) {
view.refreshSettings();
}
}
refreshSharedThreadListFromOpenSurface(): void {
const chatView = this.options.panels.panelViews().find((view) => view.openPanelSnapshot().connected);
if (chatView) {
void chatView.refreshSharedThreadList();
return;
}
const threadsView = this.threadsViews().at(0);
if (threadsView) void threadsView.refresh();
}
applyThreadListSnapshot(threads: readonly Thread[]): void {
for (const view of this.options.panels.panelViews()) {
view.applyThreadListSnapshot(threads);
}
for (const view of this.threadsViews()) {
view.applyThreadListSnapshot(threads);
}
}
publishAppServerMetadata(metadata: SharedServerMetadata): void {
for (const view of this.options.panels.panelViews()) {
view.applyAppServerMetadataSnapshot(metadata);
}
}
publishModels(models: readonly ModelMetadata[]): void {
for (const view of this.options.panels.panelViews()) {
view.applyAvailableModelsSnapshot(models);
}
}
refreshThreadsViewLiveState(): void {
for (const view of this.threadsViews()) {
view.refreshLiveState();
}
}
notifyThreadArchived(threadId: string, options: { closeOpenPanels?: boolean } = {}): void {
const leavesToClose = options.closeOpenPanels ? this.options.panels.panelLeavesForThread(threadId) : [];
for (const view of this.options.panels.panelViews()) {
view.notifyThreadArchived(threadId);
}
for (const leaf of leavesToClose) {
leaf.detach();
}
this.options.refreshThreadSurfaces();
}
notifyThreadRenamed(threadId: string, name: string | null): void {
for (const view of this.options.panels.panelViews()) {
view.notifyThreadRenamed(threadId, name);
}
this.options.refreshThreadSurfaces();
}
private threadsViews(): CodexThreadsView[] {
return this.options.app.workspace
.getLeavesOfType(VIEW_TYPE_CODEX_THREADS)
.flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : []));
}
}

View file

@ -3,7 +3,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { scheduleAppServerWarmup } from "../../../../src/features/chat/connection/app-server-warmup";
import { ChatViewDeferredTasks } from "../../../../src/features/chat/lifecycle";
import { createChatViewDeferredTasks } from "../../../../src/features/chat/lifecycle";
function createWarmupHost({
opened = true,
@ -12,7 +12,7 @@ function createWarmupHost({
}: { opened?: boolean; closing?: boolean; connected?: boolean } = {}) {
const ensureConnected = vi.fn().mockResolvedValue(undefined);
const host = {
deferredTasks: new ChatViewDeferredTasks(() => window),
deferredTasks: createChatViewDeferredTasks(() => window),
opened: () => opened,
closing: () => closing,
connected: () => connected,

View file

@ -43,16 +43,10 @@ function createController({ client = {} as AppServerClient } = {}) {
});
const host: PlanImplementationHost = {
stateStore,
connection: {
currentClient: () => client,
ensureConnected,
},
submission: {
sendTurnText,
},
runtime: {
requestDefaultCollaborationModeForNextTurn,
},
currentClient: () => client,
ensureConnected,
sendTurnText,
requestDefaultCollaborationModeForNextTurn,
};
return {
controller: createPlanImplementation(host),

View file

@ -6,10 +6,6 @@ import { createChatState, createChatStateStore } from "../../../../../src/featur
import {
createSlashCommandHandler,
type SlashCommandHandlerHost,
type SlashCommandGoalPort,
type SlashCommandRuntimePort,
type SlashCommandStatusPort,
type SlashCommandThreadPort,
} from "../../../../../src/features/chat/conversation/turns/slash-command-handler";
import type { Thread } from "../../../../../src/domain/threads/model";
@ -26,26 +22,17 @@ function thread(id: string, name: string | null = null): Thread {
};
}
interface SlashCommandHostOverrides extends Partial<Omit<SlashCommandHandlerHost, "threads" | "runtime" | "goals" | "status">> {
threads?: Partial<SlashCommandThreadPort>;
runtime?: Partial<SlashCommandRuntimePort>;
goals?: Partial<SlashCommandGoalPort>;
status?: Partial<SlashCommandStatusPort>;
}
type SlashCommandHostOverrides = Partial<SlashCommandHandlerHost>;
function createHost(overrides: SlashCommandHostOverrides = {}) {
const {
threads: threadOverrides,
runtime: runtimeOverrides,
goals: goalOverrides,
status: statusOverrides,
...hostOverrides
} = overrides;
const stateStore = createChatStateStore(createChatState());
const threadTurnsList = vi.fn().mockResolvedValue({ data: [] });
const client = { threadTurnsList } as unknown as AppServerClient;
const compactThread = vi.fn().mockResolvedValue(undefined);
const threads: SlashCommandThreadPort = {
const host: SlashCommandHandlerHost = {
stateStore,
currentClient: () => client,
codexInput: vi.fn((text: string) => textInput(text)),
startNewThread: vi.fn().mockResolvedValue(undefined),
startThreadForGoal: vi.fn().mockResolvedValue("thread-new"),
resumeThread: vi.fn().mockResolvedValue(undefined),
@ -55,9 +42,6 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
archiveThread: vi.fn().mockResolvedValue(undefined),
renameThread: vi.fn().mockResolvedValue(undefined),
reconnect: vi.fn().mockResolvedValue(undefined),
...threadOverrides,
};
const runtime: SlashCommandRuntimePort = {
toggleFastMode: vi.fn(),
toggleCollaborationMode: vi.fn(),
toggleAutoReview: vi.fn(),
@ -65,9 +49,10 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
resetModelToConfig: vi.fn(),
requestReasoningEffort: vi.fn(),
resetReasoningEffortToConfig: vi.fn(),
...runtimeOverrides,
};
const status: SlashCommandStatusPort = {
activeGoal: vi.fn(() => stateStore.getState().activeThread.goal),
setGoalObjective: vi.fn().mockResolvedValue(true),
setGoalStatus: vi.fn().mockResolvedValue(true),
clearGoal: vi.fn().mockResolvedValue(true),
addSystemMessage: vi.fn(),
addStructuredSystemMessage: vi.fn(),
setStatus: vi.fn(),
@ -76,24 +61,7 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
mcpStatusLines: vi.fn().mockResolvedValue([]),
modelStatusLines: () => [],
effortStatusLines: () => [],
...statusOverrides,
};
const goals: SlashCommandGoalPort = {
activeGoal: vi.fn(() => stateStore.getState().activeThread.goal),
setObjective: vi.fn().mockResolvedValue(true),
setStatus: vi.fn().mockResolvedValue(true),
clear: vi.fn().mockResolvedValue(true),
...goalOverrides,
};
const host: SlashCommandHandlerHost = {
stateStore,
currentClient: () => client,
codexInput: vi.fn((text: string) => textInput(text)),
threads,
runtime,
goals,
status,
...hostOverrides,
...overrides,
};
return { compactThread, host, stateStore, threadTurnsList };
}
@ -105,7 +73,7 @@ describe("createSlashCommandHandler", () => {
const result = await controller.execute("clear", "");
expect(host.threads.startNewThread).toHaveBeenCalledOnce();
expect(host.startNewThread).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
@ -159,8 +127,8 @@ describe("createSlashCommandHandler", () => {
await controller.execute("goal", "set Ship this");
expect(host.threads.startThreadForGoal).toHaveBeenCalledWith("Ship this");
expect(host.goals.setObjective).toHaveBeenCalledWith("thread-new", "Ship this", null);
expect(host.startThreadForGoal).toHaveBeenCalledWith("Ship this");
expect(host.setGoalObjective).toHaveBeenCalledWith("thread-new", "Ship this", null);
});
it("runs reconnect even when there is no current app-server client", async () => {
@ -169,7 +137,7 @@ describe("createSlashCommandHandler", () => {
await controller.execute("reconnect", "");
expect(host.threads.reconnect).toHaveBeenCalledOnce();
expect(host.reconnect).toHaveBeenCalledOnce();
});
it("reports unreadable referenced threads", async () => {
@ -184,6 +152,6 @@ describe("createSlashCommandHandler", () => {
expect(threadTurnsList).toHaveBeenCalledWith("other", null, 20);
expect(result).toBeUndefined();
expect(host.status.addSystemMessage).toHaveBeenCalledWith("Referenced thread has no readable conversation turns.");
expect(host.addSystemMessage).toHaveBeenCalledWith("Referenced thread has no readable conversation turns.");
});
});

View file

@ -6,13 +6,6 @@ import { createChatState, createChatStateStore } from "../../../../../src/featur
import {
TurnSubmissionController,
type TurnSubmissionControllerHost,
type TurnSubmissionComposerPort,
type TurnSubmissionConnectionPort,
type TurnSubmissionRestoredThreadPort,
type TurnSubmissionRuntimePort,
type TurnSubmissionStatusPort,
type TurnSubmissionThreadPort,
type TurnSubmissionViewPort,
} from "../../../../../src/features/chat/conversation/turns/turn-submission-controller";
import type { Thread } from "../../../../../src/domain/threads/model";
@ -29,29 +22,9 @@ function thread(id: string): Thread {
};
}
interface TurnSubmissionHostOverrides extends Partial<
Omit<TurnSubmissionControllerHost, "connection" | "restoredThread" | "thread" | "runtime" | "composer" | "view" | "status">
> {
connection?: Partial<TurnSubmissionConnectionPort>;
restoredThread?: Partial<TurnSubmissionRestoredThreadPort>;
thread?: Partial<TurnSubmissionThreadPort>;
runtime?: Partial<TurnSubmissionRuntimePort>;
composer?: Partial<TurnSubmissionComposerPort>;
view?: Partial<TurnSubmissionViewPort>;
status?: Partial<TurnSubmissionStatusPort>;
}
type TurnSubmissionHostOverrides = Partial<TurnSubmissionControllerHost>;
function createHost(overrides: TurnSubmissionHostOverrides = {}) {
const {
connection: connectionOverrides,
restoredThread: restoredThreadOverrides,
thread: threadOverrides,
runtime: runtimeOverrides,
composer: composerOverrides,
view: viewOverrides,
status: statusOverrides,
...hostOverrides
} = overrides;
const stateStore = createChatStateStore(createChatState());
const startTurn = vi.fn().mockResolvedValue({ turn: { id: "turn" } });
const steerTurn = vi.fn().mockResolvedValue({});
@ -59,16 +32,11 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) {
startTurn,
steerTurn,
} as unknown as AppServerClient;
const connection: TurnSubmissionConnectionPort = {
const host: TurnSubmissionControllerHost = {
stateStore,
vaultPath: "/vault",
currentClient: () => client,
...connectionOverrides,
};
const restoredThread: TurnSubmissionRestoredThreadPort = {
ensureRestoredThreadLoaded: vi.fn().mockResolvedValue(true),
...restoredThreadOverrides,
};
const threadPort: TurnSubmissionThreadPort = {
startThread: vi.fn().mockImplementation(async () => {
stateStore.dispatch({
type: "active-thread/resumed",
@ -85,37 +53,14 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) {
}),
notifyActiveThreadIdentityChanged: vi.fn(),
resetThreadTurnPresence: vi.fn(),
...threadOverrides,
};
const runtime: TurnSubmissionRuntimePort = {
applyPendingThreadSettings: vi.fn().mockResolvedValue(true),
...runtimeOverrides,
};
const composer: TurnSubmissionComposerPort = {
codexInput: vi.fn((text: string) => textInput(text)),
setDraft: vi.fn(),
...composerOverrides,
};
const view: TurnSubmissionViewPort = {
render: vi.fn(),
scheduleRender: vi.fn(),
...viewOverrides,
};
const status: TurnSubmissionStatusPort = {
setStatus: vi.fn(),
addSystemMessage: vi.fn(),
...statusOverrides,
};
const host: TurnSubmissionControllerHost = {
stateStore,
connection,
restoredThread,
thread: threadPort,
runtime,
composer,
view,
status,
...hostOverrides,
...overrides,
};
return { host, startTurn, stateStore, steerTurn };
}
@ -127,9 +72,9 @@ describe("TurnSubmissionController", () => {
await controller.sendTurnText("hello");
expect(host.thread.startThread).toHaveBeenCalledWith("hello");
expect(host.thread.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
expect(host.thread.resetThreadTurnPresence).toHaveBeenCalledWith(false);
expect(host.startThread).toHaveBeenCalledWith("hello");
expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce();
expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false);
expect(startTurn).toHaveBeenCalledWith({
threadId: "thread",
cwd: "/vault",
@ -137,9 +82,9 @@ describe("TurnSubmissionController", () => {
clientUserMessageId: expect.stringMatching(/^local-user-\d+-\d+$/),
});
expect(stateStore.getState().turn.lifecycle).toEqual({ kind: "running", turnId: "turn" });
expect(host.composer.setDraft).toHaveBeenCalledWith("");
expect(host.status.setStatus).toHaveBeenCalledWith("Turn running...");
expect(host.view.scheduleRender).toHaveBeenCalledOnce();
expect(host.setDraft).toHaveBeenCalledWith("");
expect(host.setStatus).toHaveBeenCalledWith("Turn running...");
expect(host.scheduleRender).toHaveBeenCalledOnce();
});
it("does not restore stale drafts or report stale start failures after the active thread changes", async () => {
@ -163,9 +108,9 @@ describe("TurnSubmissionController", () => {
await controller.sendTurnText("hello");
expect(host.composer.setDraft).toHaveBeenCalledWith("");
expect(host.composer.setDraft).not.toHaveBeenCalledWith("hello");
expect(host.status.addSystemMessage).not.toHaveBeenCalled();
expect(host.setDraft).toHaveBeenCalledWith("");
expect(host.setDraft).not.toHaveBeenCalledWith("hello");
expect(host.addSystemMessage).not.toHaveBeenCalled();
});
it("steers a running turn instead of starting another turn", async () => {
@ -188,7 +133,7 @@ describe("TurnSubmissionController", () => {
expect(steerTurn).toHaveBeenCalledWith("thread", "turn", textInput("follow up"), expect.stringMatching(/^local-steer-\d+-\d+$/));
expect(startTurn).not.toHaveBeenCalled();
expect(host.status.setStatus).toHaveBeenCalledWith("Steered current turn.");
expect(host.setStatus).toHaveBeenCalledWith("Steered current turn.");
const localSteerId = steerTurn.mock.calls[0]?.[3];
expect(
stateStore
@ -252,8 +197,8 @@ describe("TurnSubmissionController", () => {
await controller.sendTurnText("follow up");
expect(startTurn).not.toHaveBeenCalled();
expect(host.composer.setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(host.status.setStatus).not.toHaveBeenCalledWith("Steered current turn.");
expect(host.setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(host.setStatus).not.toHaveBeenCalledWith("Steered current turn.");
expect(stateStore.getState().messageStream.displayItems).toEqual([]);
});
@ -280,8 +225,8 @@ describe("TurnSubmissionController", () => {
await controller.sendTurnText("follow up");
expect(startTurn).not.toHaveBeenCalled();
expect(host.composer.setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(host.composer.setDraft).not.toHaveBeenCalledWith("follow up", { focus: true });
expect(host.status.addSystemMessage).not.toHaveBeenCalled();
expect(host.setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(host.setDraft).not.toHaveBeenCalledWith("follow up", { focus: true });
expect(host.addSystemMessage).not.toHaveBeenCalled();
});
});

View file

@ -1,73 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createServerDiagnostics } from "../../../../src/domain/server/diagnostics";
import { emptyRuntimeConfigSnapshot } from "../../../../src/app-server/protocol/runtime-config";
import { applyCachedSharedAppServerState } from "../../../../src/features/chat/panel/cached-app-server-state";
import type { SharedServerMetadata } from "../../../../src/app-server/services/shared-cache-state";
import type { Thread } from "../../../../src/domain/threads/model";
describe("cached app-server state", () => {
it("does not apply missing cached thread snapshots as shared truth", () => {
const serverThreads = { applyThreadList: vi.fn(), loadThreadList: vi.fn(), startThread: vi.fn() };
const serverMetadata = { ...metadataActions(), applyAppServerMetadata: vi.fn() };
applyCachedSharedAppServerState(
{
cachedThreadList: () => null,
cachedAppServerMetadata: () => null,
},
serverThreads,
serverMetadata,
);
expect(serverThreads.applyThreadList).not.toHaveBeenCalled();
expect(serverMetadata.applyAppServerMetadata).not.toHaveBeenCalled();
});
it("applies cached thread lists, including empty ready snapshots, and metadata independently when present", () => {
const threads: Thread[] = [];
const metadata = metadataSnapshot();
const serverThreads = { applyThreadList: vi.fn(), loadThreadList: vi.fn(), startThread: vi.fn() };
const serverMetadata = { ...metadataActions(), applyAppServerMetadata: vi.fn() };
applyCachedSharedAppServerState(
{
cachedThreadList: () => threads,
cachedAppServerMetadata: () => metadata,
},
serverThreads,
serverMetadata,
);
expect(serverThreads.applyThreadList).toHaveBeenCalledWith(threads);
expect(serverMetadata.applyAppServerMetadata).toHaveBeenCalledWith(metadata);
});
});
function metadataActions() {
return {
serverMetadataSnapshot: vi.fn(),
loadAppServerMetadata: vi.fn(),
refreshAppServerMetadata: vi.fn(),
refreshPublishedAppServerMetadata: vi.fn(),
publishAppServerMetadataSnapshot: vi.fn(),
refreshModels: vi.fn(),
loadModels: vi.fn(),
refreshSkills: vi.fn(),
refreshPublishedSkills: vi.fn(),
loadSkills: vi.fn(),
refreshRateLimits: vi.fn(),
refreshPublishedRateLimits: vi.fn(),
loadRateLimit: vi.fn(),
};
}
function metadataSnapshot(): SharedServerMetadata {
return {
runtimeConfig: emptyRuntimeConfigSnapshot(),
availableModels: [],
availableSkills: [],
rateLimit: null,
serverDiagnostics: createServerDiagnostics(),
};
}

View file

@ -3,45 +3,45 @@
import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../../src/features/chat/state/reducer";
import { createToolbarArchiveConfirmState, ToolbarPanelController } from "../../../../../src/features/chat/panel/regions/toolbar";
import type { ChatThreadActions } from "../../../../../src/features/chat/threads/actions";
import { createToolbarArchiveConfirmState, createToolbarPanelActions } from "../../../../../src/features/chat/panel/regions/toolbar";
import type { ChatThreadActions } from "../../../../../src/features/chat/threads/action-context";
describe("ToolbarPanelController", () => {
describe("createToolbarPanelActions", () => {
it("tracks archive confirmation and delegates archive actions", async () => {
const stateStore = createChatStateStore(createChatState());
const archiveThread = vi.fn().mockResolvedValue(undefined);
const scheduleRender = vi.fn();
const controller = new ToolbarPanelController({
const actions = createToolbarPanelActions({
stateStore,
threadActions: { archiveThread } as unknown as ChatThreadActions,
archiveConfirm: createToolbarArchiveConfirmState(),
scheduleRender,
});
controller.startArchive("thread");
expect(controller.archiveConfirmId()).toBe("thread");
actions.startArchive("thread");
expect(actions.archiveConfirmId()).toBe("thread");
expect(scheduleRender).not.toHaveBeenCalled();
await controller.archiveThread("thread", true);
await actions.archiveThread("thread", true);
expect(archiveThread).toHaveBeenCalledWith("thread", true);
expect(controller.archiveConfirmId()).toBeNull();
expect(actions.archiveConfirmId()).toBeNull();
expect(scheduleRender).toHaveBeenCalledWith();
});
it("closes mutually exclusive toolbar panels on outside pointers", () => {
const stateStore = createChatStateStore(createChatState());
const scheduleRender = vi.fn();
const controller = new ToolbarPanelController({
const actions = createToolbarPanelActions({
stateStore,
threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActions,
archiveConfirm: createToolbarArchiveConfirmState(),
scheduleRender,
});
controller.toggleHistory();
actions.toggleHistory();
expect(stateStore.getState().ui.toolbarPanel).toBe("history");
controller.closeOnOutsidePointer({
actions.closeOnOutsidePointer({
target: document.createElement("button"),
viewWindow: window,
contains: () => false,

View file

@ -13,28 +13,40 @@ vi.mock("../../../../src/features/chat/ui/shell", () => ({
function createHost(overrides: Partial<ChatViewLifecycleHost> = {}) {
const root = document.createElement("div");
const host: ChatViewLifecycleHost = {
setOpened: vi.fn(),
setClosing: vi.fn(),
registerEvent: vi.fn(),
registerComposerNoteIndexInvalidation: vi.fn((register) => {
register({} as EventRef);
}),
registerPointerDown: vi.fn(),
applyCachedSharedAppServerState: vi.fn(),
render: vi.fn(),
scheduleDeferredAppServerWarmup: vi.fn(),
scheduleDeferredRestoredThreadHydration: vi.fn(),
closeToolbarPanelOnOutsidePointer: vi.fn(),
invalidateConnectionWork: vi.fn(),
invalidateResumeWork: vi.fn(),
clearDeferredTasks: vi.fn(),
panelRoot: () => root,
disposeMessages: vi.fn(),
disposeComposer: vi.fn(),
disconnect: vi.fn(),
clearClient: vi.fn(),
refreshLiveState: vi.fn(),
deferRefreshLiveState: vi.fn(),
lifecycle: {
setOpened: vi.fn(),
setClosing: vi.fn(),
invalidateConnectionWork: vi.fn(),
invalidateResumeWork: vi.fn(),
clearDeferredTasks: vi.fn(),
scheduleDeferredAppServerWarmup: vi.fn(),
scheduleDeferredRestoredThreadHydration: vi.fn(),
},
events: {
registerEvent: vi.fn(),
registerComposerNoteIndexInvalidation: vi.fn((register) => {
register({} as EventRef);
}),
registerPointerDown: vi.fn(),
closeToolbarPanelOnOutsidePointer: vi.fn(),
},
render: {
panelRoot: () => root,
now: vi.fn(),
},
sharedState: {
applyCachedAppServerState: vi.fn(),
},
resources: {
disposeMessages: vi.fn(),
disposeComposer: vi.fn(),
disconnect: vi.fn(),
clearClient: vi.fn(),
},
liveState: {
refresh: vi.fn(),
deferRefresh: vi.fn(),
},
...overrides,
};
return { host, root };
@ -50,14 +62,14 @@ describe("chat view lifecycle", () => {
openChatView(host);
expect(host.setOpened).toHaveBeenCalledWith(true);
expect(host.setClosing).toHaveBeenCalledWith(false);
expect(host.registerEvent).toHaveBeenCalledOnce();
expect(host.registerPointerDown).toHaveBeenCalledOnce();
expect(host.applyCachedSharedAppServerState).toHaveBeenCalledOnce();
expect(host.render).toHaveBeenCalledOnce();
expect(host.scheduleDeferredAppServerWarmup).toHaveBeenCalledOnce();
expect(host.scheduleDeferredRestoredThreadHydration).toHaveBeenCalledOnce();
expect(host.lifecycle.setOpened).toHaveBeenCalledWith(true);
expect(host.lifecycle.setClosing).toHaveBeenCalledWith(false);
expect(host.events.registerEvent).toHaveBeenCalledOnce();
expect(host.events.registerPointerDown).toHaveBeenCalledOnce();
expect(host.sharedState.applyCachedAppServerState).toHaveBeenCalledOnce();
expect(host.render.now).toHaveBeenCalledOnce();
expect(host.lifecycle.scheduleDeferredAppServerWarmup).toHaveBeenCalledOnce();
expect(host.lifecycle.scheduleDeferredRestoredThreadHydration).toHaveBeenCalledOnce();
});
it("disposes mounted resources and refreshes live state on close", () => {
@ -65,15 +77,15 @@ describe("chat view lifecycle", () => {
closeChatView(host);
expect(host.setOpened).toHaveBeenCalledWith(false);
expect(host.setClosing).toHaveBeenCalledWith(true);
expect(host.clearDeferredTasks).toHaveBeenCalledOnce();
expect(host.disposeMessages).toHaveBeenCalledOnce();
expect(host.disposeComposer).toHaveBeenCalledOnce();
expect(host.lifecycle.setOpened).toHaveBeenCalledWith(false);
expect(host.lifecycle.setClosing).toHaveBeenCalledWith(true);
expect(host.lifecycle.clearDeferredTasks).toHaveBeenCalledOnce();
expect(host.resources.disposeMessages).toHaveBeenCalledOnce();
expect(host.resources.disposeComposer).toHaveBeenCalledOnce();
expect(unmountChatPanelShell).toHaveBeenCalledWith(root);
expect(host.disconnect).toHaveBeenCalledOnce();
expect(host.clearClient).toHaveBeenCalledOnce();
expect(host.refreshLiveState).toHaveBeenCalledOnce();
expect(host.deferRefreshLiveState).toHaveBeenCalledOnce();
expect(host.resources.disconnect).toHaveBeenCalledOnce();
expect(host.resources.clearClient).toHaveBeenCalledOnce();
expect(host.liveState.refresh).toHaveBeenCalledOnce();
expect(host.liveState.deferRefresh).toHaveBeenCalledOnce();
});
});

View file

@ -1,29 +0,0 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { ChatViewRenderController, type ChatViewRenderControllerHost } from "../../../../src/features/chat/panel/view-render-controller";
describe("ChatViewRenderController", () => {
it("renders the shell through the configured panel root", () => {
const root = document.createElement("div");
const renderShell = vi.fn();
const host = renderHost(root, renderShell);
const controller = new ChatViewRenderController(host);
controller.render();
expect(host.clearScheduledRender).toHaveBeenCalledOnce();
expect(renderShell).toHaveBeenCalledWith(root);
});
});
function renderHost(root: HTMLElement, renderShell: (root: HTMLElement) => void): ChatViewRenderControllerHost {
return {
shell: {
render: renderShell,
},
panelRoot: () => root,
clearScheduledRender: vi.fn(),
};
}

View file

@ -4,7 +4,12 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { ArchiveExportAdapter } from "../../../../src/features/thread-export/archive-markdown";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/state/reducer";
import { createChatThreadActions, type ChatThreadActionsHost } from "../../../../src/features/chat/threads/actions";
import { archiveThread } from "../../../../src/features/chat/threads/archive-actions";
import { compactThread } from "../../../../src/features/chat/threads/compact-actions";
import { forkThreadFromTurn } from "../../../../src/features/chat/threads/fork-actions";
import { renameThread } from "../../../../src/features/chat/threads/rename-actions";
import { rollbackThread as rollbackThreadAction } from "../../../../src/features/chat/threads/rollback-actions";
import type { ChatThreadActions, ChatThreadActionsHost } from "../../../../src/features/chat/threads/action-context";
import type { DisplayItem } from "../../../../src/features/chat/display/types";
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
import { notices } from "../../../mocks/obsidian";
@ -16,7 +21,7 @@ type MockArchiveExportAdapter = ArchiveExportAdapter & {
write: ReturnType<typeof vi.fn<ArchiveExportAdapter["write"]>>;
};
describe("createChatThreadActions", () => {
describe("chat thread actions", () => {
beforeEach(() => {
notices.length = 0;
});
@ -24,7 +29,7 @@ describe("createChatThreadActions", () => {
it("requests thread compaction and reports the shared status", async () => {
const client = clientMock();
const host = hostMock({ client, displayItems: [] });
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await controller.compactThread("source");
@ -50,7 +55,7 @@ describe("createChatThreadActions", () => {
approvalsReviewer: null,
activePermissionProfile: null,
});
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
const pendingCompact = controller.compactThread("source");
await waitForAsyncWork(() => {
@ -88,7 +93,7 @@ describe("createChatThreadActions", () => {
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await controller.archiveThread("source");
@ -120,7 +125,7 @@ describe("createChatThreadActions", () => {
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await controller.archiveThread("source");
@ -133,7 +138,7 @@ describe("createChatThreadActions", () => {
it("forks from a selected turn by dropping later turns on the fork", async () => {
const client = clientMock();
const host = hostMock({ client, displayItems: turnItems() });
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-1", false);
@ -158,7 +163,7 @@ describe("createChatThreadActions", () => {
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
@ -177,7 +182,7 @@ describe("createChatThreadActions", () => {
const client = clientMock();
client.archiveThread.mockRejectedValue(new Error("archive failed"));
const host = hostMock({ client, displayItems: turnItems() });
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
@ -192,7 +197,7 @@ describe("createChatThreadActions", () => {
const client = clientMock();
const host = hostMock({ client, displayItems: turnItems() });
host.openThreadInCurrentPanel.mockRejectedValue(new Error("resume failed"));
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
@ -222,7 +227,7 @@ describe("createChatThreadActions", () => {
threads: [{ ...panelThread("source"), name: "Source name" }],
threadsLoaded: true,
});
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
const pendingFork = controller.forkThreadFromTurn("source", null, true);
await waitForAsyncWork(() => {
@ -253,7 +258,7 @@ describe("createChatThreadActions", () => {
const client = clientMock();
const host = hostMock({ client, displayItems: [] });
host.stateStore.dispatch({ type: "thread-list/applied", threads: [{ ...panelThread("thread"), name: "Old" }] });
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await expect(controller.renameThread("thread", " Slash command title ")).resolves.toBe(true);
@ -267,7 +272,7 @@ describe("createChatThreadActions", () => {
it("ignores empty thread rename titles", async () => {
const client = clientMock();
const host = hostMock({ client, displayItems: [] });
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await expect(controller.renameThread("thread", " ")).resolves.toBe(false);
@ -291,7 +296,7 @@ describe("createChatThreadActions", () => {
activePermissionProfile: null,
});
host.stateStore.dispatch({ type: "message-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false });
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
await controller.rollbackThread("source");
@ -324,7 +329,7 @@ describe("createChatThreadActions", () => {
activePermissionProfile: null,
});
host.stateStore.dispatch({ type: "message-stream/items-replaced", items: turnItems(), historyCursor: null, loadingHistory: false });
const controller = createChatThreadActions(host);
const controller = chatThreadActions(host);
const pendingRollback = controller.rollbackThread("source");
await waitForAsyncWork(() => {
@ -397,6 +402,17 @@ function clientMock() {
};
}
function chatThreadActions(host: ChatThreadActionsHost): ChatThreadActions {
return {
compactThread: (threadId) => compactThread(host, threadId),
archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown),
forkThread: (threadId) => forkThreadFromTurn(host, threadId, null, false),
forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource),
renameThread: (threadId, name) => renameThread(host, threadId, name),
rollbackThread: (threadId) => rollbackThreadAction(host, threadId),
};
}
function hostMock({
client,
displayItems,

View file

@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/state/reducer";
import { RestorationController } from "../../../../src/features/chat/threads/restoration-controller";
import { ChatViewDeferredTasks } from "../../../../src/features/chat/lifecycle";
import { createChatViewDeferredTasks } from "../../../../src/features/chat/lifecycle";
import { deferred } from "../../../support/async";
describe("RestorationController", () => {
@ -13,7 +13,7 @@ describe("RestorationController", () => {
const stateStore = createChatStateStore(createChatState());
const resumeThread = vi.fn().mockResolvedValue(undefined);
const controller = new RestorationController({
deferredTasks: new ChatViewDeferredTasks(() => window),
deferredTasks: createChatViewDeferredTasks(() => window),
opened: () => true,
resumeThread,
invalidateResumeWork: vi.fn(),
@ -59,7 +59,7 @@ describe("RestorationController", () => {
function restoredThreadControllerFixture(overrides: Partial<ConstructorParameters<typeof RestorationController>[0]> = {}) {
return new RestorationController({
deferredTasks: new ChatViewDeferredTasks(() => window),
deferredTasks: createChatViewDeferredTasks(() => window),
opened: () => false,
resumeThread: vi.fn().mockResolvedValue(undefined),
invalidateResumeWork: vi.fn(),

View file

@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { ChatMessageScrollIntentController } from "../../../../../src/features/chat/ui/message-stream/scroll-intent-controller";
describe("ChatMessageScrollIntentController", () => {
it("consumes one-shot scroll intents", () => {
const controller = new ChatMessageScrollIntentController();
controller.preservePosition();
expect(controller.consumeIntent()).toBe("preserve");
expect(controller.consumeIntent()).toBe("auto");
});
it("emits a one-shot force-bottom intent", () => {
const controller = new ChatMessageScrollIntentController();
controller.forceBottom();
expect(controller.consumeIntent()).toBe("force-bottom");
});
it("uses the latest scroll intent before consumption", () => {
const controller = new ChatMessageScrollIntentController();
controller.preservePosition();
controller.followBottom();
controller.forceBottom();
expect(controller.consumeIntent()).toBe("force-bottom");
expect(controller.consumeIntent()).toBe("auto");
});
});

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { createChatMessageScrollIntentState } from "../../../../../src/features/chat/ui/message-stream/scroll-intent-state";
describe("createChatMessageScrollIntentState", () => {
it("consumes one-shot scroll intents", () => {
const scrollIntent = createChatMessageScrollIntentState();
scrollIntent.preservePosition();
expect(scrollIntent.consumeIntent()).toBe("preserve");
expect(scrollIntent.consumeIntent()).toBe("auto");
});
it("emits a one-shot force-bottom intent", () => {
const scrollIntent = createChatMessageScrollIntentState();
scrollIntent.forceBottom();
expect(scrollIntent.consumeIntent()).toBe("force-bottom");
});
it("uses the latest scroll intent before consumption", () => {
const scrollIntent = createChatMessageScrollIntentState();
scrollIntent.preservePosition();
scrollIntent.followBottom();
scrollIntent.forceBottom();
expect(scrollIntent.consumeIntent()).toBe("force-bottom");
expect(scrollIntent.consumeIntent()).toBe("auto");
});
});

View file

@ -6,11 +6,15 @@ import { act } from "preact/test-utils";
import type { Thread } from "../../../../src/domain/threads/model";
import { createChatStateStore } from "../../../../src/features/chat/state/reducer";
import { runtimeSnapshotForChatState } from "../../../../src/features/chat/runtime/snapshot";
import { createToolbarArchiveConfirmState, ToolbarPanelController } from "../../../../src/features/chat/panel/regions/toolbar";
import {
createToolbarArchiveConfirmState,
createToolbarPanelActions,
type ToolbarPanelActions,
} from "../../../../src/features/chat/panel/regions/toolbar";
import type { ChatPanelToolbarPorts } from "../../../../src/features/chat/panel/regions/ports";
import type { ChatThreadActions } from "../../../../src/features/chat/threads/actions";
import type { ChatThreadActions } from "../../../../src/features/chat/threads/action-context";
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
import { chatPanelToolbarRegionNode } from "../../../../src/features/chat/panel/regions/render";
import { chatPanelToolbarRegionNode } from "../../../../src/features/chat/panel/regions/toolbar";
import { installObsidianDomShims } from "../../../support/dom";
installObsidianDomShims();
@ -22,7 +26,7 @@ describe("chat toolbar archive confirmation signal", () => {
const container = document.createElement("div");
const archiveConfirm = createToolbarArchiveConfirmState();
const scheduleRender = vi.fn();
const controller = new ToolbarPanelController({
const toolbarActions = createToolbarPanelActions({
stateStore: store,
threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActions,
archiveConfirm,
@ -36,7 +40,7 @@ describe("chat toolbar archive confirmation signal", () => {
renderChatPanelShell(container, {
stateStore: store,
showToolbar: true,
toolbarNode: () => chatPanelToolbarRegionNode(toolbarPorts(store, controller)),
toolbarNode: () => chatPanelToolbarRegionNode(toolbarPorts(store, toolbarActions)),
goalNode: () => null,
messageStreamNode: () => <div className="codex-panel__region codex-panel__region--message-stream codex-panel__messages" />,
composerNode: () => null,
@ -47,7 +51,7 @@ describe("chat toolbar archive confirmation signal", () => {
expect(container.querySelector(".codex-panel__archive-confirm")).toBeNull();
await act(async () => {
controller.startArchive("thread-1");
toolbarActions.startArchive("thread-1");
await settle();
});
@ -60,7 +64,7 @@ describe("chat toolbar archive confirmation signal", () => {
});
});
function toolbarPorts(store: ReturnType<typeof createChatStateStore>, controller: ToolbarPanelController): ChatPanelToolbarPorts {
function toolbarPorts(store: ReturnType<typeof createChatStateStore>, toolbarActions: ToolbarPanelActions): ChatPanelToolbarPorts {
return {
state: {
chat: () => store.getState(),
@ -77,8 +81,8 @@ function toolbarPorts(store: ReturnType<typeof createChatStateStore>, controller
},
view: {
toolbar: {
archiveConfirmId: () => controller.archiveConfirmId(),
archiveConfirmSubscribe: (listener) => controller.onArchiveConfirmChange(listener),
archiveConfirmId: () => toolbarActions.archiveConfirmId(),
archiveConfirmSubscribe: (listener) => toolbarActions.onArchiveConfirmChange(listener),
renameState: () => null,
renameSubscribe: () => () => undefined,
},
@ -95,7 +99,7 @@ function toolbarPorts(store: ReturnType<typeof createChatStateStore>, controller
refreshStatus: vi.fn(),
resumeThread: vi.fn(),
startArchiveThread: (threadId) => {
controller.startArchive(threadId);
toolbarActions.startArchive(threadId);
},
archiveThread: vi.fn(),
startRenameThread: vi.fn(),

View file

@ -5,20 +5,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ChatConnectionWorkTracker,
ChatResumeWorkTracker,
ChatViewDeferredTasks,
createChatViewDeferredTasks,
transitionChatConnectionLifecycle,
transitionChatResumeLifecycle,
transitionRestoredThreadLifecycle,
type ActiveChatConnection,
} from "../../../src/features/chat/lifecycle";
describe("ChatViewDeferredTasks", () => {
describe("createChatViewDeferredTasks", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("coalesces scheduled renders", async () => {
const tasks = new ChatViewDeferredTasks(() => window);
const tasks = createChatViewDeferredTasks(() => window);
const render = vi.fn();
tasks.scheduleRender(render);
@ -30,7 +30,7 @@ describe("ChatViewDeferredTasks", () => {
});
it("clears scheduled deferred work", async () => {
const tasks = new ChatViewDeferredTasks(() => window);
const tasks = createChatViewDeferredTasks(() => window);
const render = vi.fn();
const diagnostics = vi.fn();
const hydration = vi.fn();

View file

@ -30,15 +30,15 @@ import type {
AppServerClientHandlers,
AppServerStartStructuredTurnOptions,
} from "../../../src/app-server/connection/client";
import type { AppServerInitialization } from "../../../src/app-server/protocol/initialization";
import type { RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn";
import type { ModelMetadata, ReasoningEffort } from "../../../src/domain/catalog/metadata";
import type { ServerInitialization } from "../../../src/domain/server/initialization";
import type { ComposerSendKeyEvent } from "../../../src/shared/ui/keyboard";
import { deferred } from "../../support/async";
import { installObsidianDomShims } from "../../support/dom";
type InitializeResponse = AppServerInitialization;
type InitializeResponse = ServerInitialization;
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
type Turn = TurnRecord;

View file

@ -7,10 +7,10 @@ import type {
AppServerClientHandlers,
AppServerStartStructuredTurnOptions,
} from "../../../src/app-server/connection/client";
import type { AppServerInitialization } from "../../../src/app-server/protocol/initialization";
import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn";
import type { RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
import type { ModelMetadata } from "../../../src/domain/catalog/metadata";
import type { ServerInitialization } from "../../../src/domain/server/initialization";
import {
generateThreadTitleWithCodex,
threadTitleFromGenerationTurn,
@ -27,7 +27,7 @@ import {
threadTitlePrompt,
} from "../../../src/features/thread-title/model";
type InitializeResponse = AppServerInitialization;
type InitializeResponse = ServerInitialization;
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
type Turn = TurnRecord;

View file

@ -3,18 +3,18 @@
import { describe, expect, it, vi } from "vitest";
import {
ThreadsViewDeferredTasks,
createThreadsViewDeferredTasks,
transitionThreadsViewConnectionLifecycle,
transitionThreadsViewRefreshLifecycle,
type ActiveThreadsViewConnection,
type ActiveThreadsViewRefresh,
} from "../../../src/features/threads-view/view-lifecycle";
describe("ThreadsViewDeferredTasks", () => {
describe("createThreadsViewDeferredTasks", () => {
it("coalesces render and refresh callbacks", () => {
vi.useFakeTimers();
try {
const tasks = new ThreadsViewDeferredTasks(() => window);
const tasks = createThreadsViewDeferredTasks(() => window);
const render = vi.fn();
const refresh = vi.fn();
@ -37,7 +37,7 @@ describe("ThreadsViewDeferredTasks", () => {
it("clears pending callbacks", () => {
vi.useFakeTimers();
try {
const tasks = new ThreadsViewDeferredTasks(() => window);
const tasks = createThreadsViewDeferredTasks(() => window);
const render = vi.fn();
const refresh = vi.fn();

View file

@ -12,7 +12,7 @@ import type { Thread } from "../src/domain/threads/model";
import type { SharedAppServerCache } from "../src/app-server/services/shared-cache";
import type { SharedAppServerCacheContext } from "../src/app-server/services/shared-cache-state";
import type { WorkspacePanelCoordinator } from "../src/workspace/panel-coordinator";
import type { ThreadSurfaceCoordinator } from "../src/workspace/thread-surface-coordinator";
import type { ThreadSurfaceActions } from "../src/workspace/thread-surface-actions";
import { installObsidianDomShims } from "./support/dom";
installObsidianDomShims();
@ -21,8 +21,8 @@ function panels(plugin: CodexPanelPlugin): WorkspacePanelCoordinator {
return (plugin as unknown as { panels: WorkspacePanelCoordinator }).panels;
}
function threadSurfaces(plugin: CodexPanelPlugin): ThreadSurfaceCoordinator {
return (plugin as unknown as { threadSurfaces: ThreadSurfaceCoordinator }).threadSurfaces;
function threadSurfaces(plugin: CodexPanelPlugin): ThreadSurfaceActions {
return (plugin as unknown as { threadSurfaces: ThreadSurfaceActions }).threadSurfaces;
}
function sharedAppServerCache(plugin: CodexPanelPlugin): SharedAppServerCache {

View file

@ -2,14 +2,14 @@ import { describe, expect, it, vi } from "vitest";
import { VIEW_TYPE_CODEX_THREADS } from "../../src/constants";
import { CodexThreadsView } from "../../src/features/threads-view/view";
import { ThreadSurfaceCoordinator } from "../../src/workspace/thread-surface-coordinator";
import { createThreadSurfaceActions } from "../../src/workspace/thread-surface-actions";
import type { OpenCodexPanelSnapshot } from "../../src/workspace/open-panel-snapshot";
describe("ThreadSurfaceCoordinator", () => {
describe("createThreadSurfaceActions", () => {
it("falls back to the threads view when no connected chat panel can refresh shared threads", () => {
const disconnectedPanelRefresh = vi.fn().mockResolvedValue(undefined);
const threadsRefresh = vi.fn().mockResolvedValue(undefined);
const coordinator = new ThreadSurfaceCoordinator({
const threadSurfaces = createThreadSurfaceActions({
app: {
workspace: {
getLeavesOfType: vi.fn((type: string) =>
@ -28,7 +28,7 @@ describe("ThreadSurfaceCoordinator", () => {
refreshThreadSurfaces: vi.fn(),
});
coordinator.refreshSharedThreadListFromOpenSurface();
threadSurfaces.refreshSharedThreadListFromOpenSurface();
expect(disconnectedPanelRefresh).not.toHaveBeenCalled();
expect(threadsRefresh).toHaveBeenCalledOnce();
@ -38,7 +38,7 @@ describe("ThreadSurfaceCoordinator", () => {
const disconnectedPanelRefresh = vi.fn().mockResolvedValue(undefined);
const connectedPanelRefresh = vi.fn().mockResolvedValue(undefined);
const threadsRefresh = vi.fn().mockResolvedValue(undefined);
const coordinator = new ThreadSurfaceCoordinator({
const threadSurfaces = createThreadSurfaceActions({
app: {
workspace: {
getLeavesOfType: vi.fn((type: string) =>
@ -61,7 +61,7 @@ describe("ThreadSurfaceCoordinator", () => {
refreshThreadSurfaces: vi.fn(),
});
coordinator.refreshSharedThreadListFromOpenSurface();
threadSurfaces.refreshSharedThreadListFromOpenSurface();
expect(disconnectedPanelRefresh).not.toHaveBeenCalled();
expect(connectedPanelRefresh).toHaveBeenCalledOnce();