Simplify shared thread catalog and chat runtime ownership

This commit is contained in:
murashit 2026-06-15 11:32:45 +09:00
parent b40d6cdb5b
commit 40076477fc
32 changed files with 1791 additions and 1180 deletions

View file

@ -6,8 +6,7 @@ import type { ChatTurnDiffViewState } from "../../domain/turn-diff";
export interface CodexChatHost {
readonly settingsRef: PluginSettingsRef;
readonly workspace: WorkspacePanels;
readonly sharedCache: SharedAppServerCacheFacade;
readonly threadSurfaces: ThreadSurfaceBroadcaster;
readonly threadCatalog: ThreadCatalogFacade;
}
export interface PluginSettingsRef {
@ -21,17 +20,14 @@ export interface WorkspacePanels {
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
}
export interface ThreadSurfaceBroadcaster {
export interface ThreadCatalogFacade {
notifyThreadArchived(threadId: string): void;
notifyThreadRenamed(threadId: string, name: string | null): void;
refreshThreadsViewLiveState(): void;
refreshSharedThreadListFromOpenSurface(): void;
applyThreadListSnapshot(threads: readonly Thread[]): void;
refreshFromOpenSurface(): void;
applyThreads(threads: readonly Thread[]): void;
publishAppServerMetadata(metadata: SharedServerMetadata): void;
}
interface SharedAppServerCacheFacade {
refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
cachedThreadList(): readonly Thread[] | null;
refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
cachedThreads(): readonly Thread[] | null;
cachedAppServerMetadata(): SharedServerMetadata | null;
}

View file

@ -1,22 +1,15 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { Thread } from "../../../../domain/threads/model";
import type { ThreadConversationSummary } from "../../../../domain/threads/transcript";
import type { CodexPanelSettings } from "../../../../settings/model";
import { renameThreadOnAppServer, threadRenameFromValue } from "../../../../app-server/services/thread-rename";
import { generateThreadTitleWithCodex } from "../../../../app-server/services/thread-title-generation";
import { threadTitleContextFromConversationSummary, type ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
import type { ThreadOperations } from "../../../threads/thread-operations";
import type { ThreadTitleService } from "../../../threads/thread-title-service";
import type { ChatAction, ChatState } from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import { messageStreamItems } from "../state/message-stream";
import { threadTitleContextFromMessageStreamItems } from "./title-context";
export interface AutoTitleControllerHost {
stateStore: ChatStateStore;
vaultPath: string;
settings: () => CodexPanelSettings;
currentClient: () => AppServerClient | null;
notifyThreadRenamed: (threadId: string, name: string) => void;
generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null>;
operations: Pick<ThreadOperations, "renameThread">;
titleService: Pick<ThreadTitleService, "completedTurnContext" | "generate">;
}
export class AutoTitleController {
@ -45,7 +38,7 @@ export class AutoTitleController {
if (hadTurnsBeforeThisCompletion) return;
if (this.threadHasTitle(threadId)) return;
if (this.attemptedThreadIds.has(threadId) || this.inFlightThreadIds.has(threadId)) return;
const context = this.titleContextForCompletedTurn(turnId, completedSummary);
const context = this.host.titleService.completedTurnContext(turnId, completedSummary);
if (!context) return;
this.attemptedThreadIds.add(threadId);
@ -53,28 +46,20 @@ export class AutoTitleController {
void this.generateAndSetTitle(threadId, context);
}
private titleContextForCompletedTurn(turnId: string, completedSummary: ThreadConversationSummary | null): ThreadTitleContext | null {
const visibleContext = threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(this.state.messageStream));
if (visibleContext) return visibleContext;
return completedSummary ? threadTitleContextFromConversationSummary(completedSummary) : null;
}
private async generateAndSetTitle(threadId: string, context: ThreadTitleContext): Promise<void> {
try {
const title = await this.generateTitle(context);
if (!title || !this.threadCanReceiveGeneratedTitle(threadId)) return;
const rename = threadRenameFromValue(title);
if (!rename) return;
const client = this.host.currentClient();
if (!client) return;
const result = await renameThreadOnAppServer(client, threadId, rename);
const result = await this.host.operations.renameThread(threadId, title, {
shouldPublish: () => this.threadCanReceiveGeneratedTitle(threadId),
});
if (!result) return;
if (!this.threadCanReceiveGeneratedTitle(threadId)) return;
this.dispatch({
type: "thread-list/applied",
threads: this.state.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: result.name } : thread)),
});
this.host.notifyThreadRenamed(threadId, result.name);
} catch {
// Auto-title is best-effort metadata. Leave the thread preview untouched on failure.
} finally {
@ -83,12 +68,7 @@ export class AutoTitleController {
}
private async generateTitle(context: ThreadTitleContext): Promise<string | null> {
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
const settings = this.host.settings();
return generateThreadTitleWithCodex(settings.codexPath, this.host.vaultPath, context, {
threadNamingModel: settings.threadNamingModel,
threadNamingEffort: settings.threadNamingEffort,
});
return this.host.titleService.generate(context);
}
private threadHasTitle(threadId: string): boolean {

View file

@ -1,22 +1,20 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import type { ArchiveExportAdapter } from "../../../../app-server/services/thread-archive-markdown";
import type { ThreadOperations } from "../../../threads/thread-operations";
import type { ThreadTitleService } from "../../../threads/thread-title-service";
import type { GoalActions } from "./goal-actions";
import { createSelectionActions } from "./selection-actions";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle";
import type { ChatStateStore } from "../state/store";
import type { PluginSettingsRef, ThreadSurfaceBroadcaster, WorkspacePanels } from "../ports/chat-host";
import type { PluginSettingsRef, ThreadCatalogFacade, WorkspacePanels } from "../ports/chat-host";
import type { AutoTitleController } from "./auto-title-controller";
import { ThreadRenameEditorController } from "./rename-editor-controller";
import { createThreadManagementActions, type ThreadManagementActions, type ThreadManagementActionsHost } from "./thread-management-actions";
import { createThreadLifecycleParts } from "./lifecycle-parts";
interface ThreadPartsContext {
obsidian: {
archiveAdapter: () => ArchiveExportAdapter;
};
settingsRef: PluginSettingsRef;
workspace: Pick<WorkspacePanels, "focusThreadInOpenView" | "openThreadInNewView">;
threadSurfaces: ThreadSurfaceBroadcaster;
threadCatalog: Pick<ThreadCatalogFacade, "refreshFromOpenSurface">;
state: {
stateStore: ChatStateStore;
};
@ -39,9 +37,6 @@ interface ThreadPartsContext {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
};
notify: {
showNotice: (text: string) => void;
};
liveState: {
refresh: () => void;
};
@ -51,38 +46,36 @@ interface ThreadPartsContext {
};
goals: GoalActions;
autoTitle: AutoTitleController;
operations: ThreadOperations;
titleService: ThreadTitleService;
}
export function createThreadParts(context: ThreadPartsContext) {
const {
obsidian,
settingsRef,
workspace,
threadSurfaces,
threadCatalog,
state,
thread,
status,
notify,
liveState,
scroll,
client,
lifecycle,
goals,
autoTitle,
operations,
titleService,
} = context;
const stateStore = state.stateStore;
const currentClient = client.getClient;
const rename = new ThreadRenameEditorController({
stateStore,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
ensureConnected: client.ensureConnected,
currentClient,
addSystemMessage: status.addSystemMessage,
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
operations,
titleService,
});
const threadLifecycle = createThreadLifecycleParts({
settingsRef,
@ -109,28 +102,20 @@ export function createThreadParts(context: ThreadPartsContext) {
const threadManagementHost: ThreadManagementActionsHost = {
stateStore,
vaultPath: settingsRef.vaultPath,
settings: () => settingsRef.settings,
archiveAdapter: obsidian.archiveAdapter,
operations,
ensureConnected: client.ensureConnected,
currentClient,
addSystemMessage: status.addSystemMessage,
showNotice: notify.showNotice,
setStatus: status.set,
setComposerText: composer.setText,
openThreadInNewView: (threadId) => workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: (threadId) => resume.resumeThread(threadId),
notifyThreadArchived: (threadId) => {
threadSurfaces.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
},
notifyActiveThreadIdentityChanged: () => {
thread.notifyIdentityChanged();
},
refreshThreads: () => thread.refreshThreads(),
refreshSharedThreadListFromOpenSurface: () => {
threadSurfaces.refreshSharedThreadListFromOpenSurface();
refreshAfterThreadMutation: async () => {
await thread.refreshThreads();
threadCatalog.refreshFromOpenSurface();
},
};
return createThreadManagementActions(threadManagementHost);

View file

@ -1,15 +1,9 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { readCompletedConversationSummariesPage } from "../../../../app-server/services/threads";
import { getThreadTitle } from "../../../../domain/threads/model";
import type { Thread } from "../../../../domain/threads/model";
import type { CodexPanelSettings } from "../../../../settings/model";
import { threadRenameFromValue } from "../../../../app-server/services/thread-rename";
import { generateThreadTitleWithCodex } from "../../../../app-server/services/thread-title-generation";
import {
findThreadTitleContext,
THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE,
type ThreadTitleContext,
} from "../../../../domain/threads/title-generation-model";
import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model";
import type { ThreadOperations } from "../../../threads/thread-operations";
import type { ThreadTitleService } from "../../../threads/thread-title-service";
import {
renameGenerationStillActive,
type ChatAction,
@ -19,7 +13,6 @@ import {
} from "../state/root-reducer";
import type { ChatStateStore } from "../state/store";
import { messageStreamItems } from "../state/message-stream";
import { renameConnectedThread } from "./thread-management-actions";
import { firstThreadTitleContextFromMessageStreamItems } from "./title-context";
export interface RenameEditState {
@ -29,13 +22,10 @@ export interface RenameEditState {
export interface ThreadRenameEditorControllerHost {
stateStore: ChatStateStore;
vaultPath: string;
settings: () => CodexPanelSettings;
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
addSystemMessage: (text: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null>;
operations: Pick<ThreadOperations, "renameThread">;
titleService: Pick<ThreadTitleService, "generateTitle">;
}
export class ThreadRenameEditorController {
@ -92,10 +82,8 @@ export class ThreadRenameEditorController {
await this.host.ensureConnected();
if (this.renameState !== editingState) return;
const client = this.host.currentClient();
if (!client) return;
if (await renameConnectedThread(this.host, threadId, rename)) {
if (await this.host.operations.renameThread(threadId, rename.name)) {
if (this.renameState === editingState) this.clear();
}
}
@ -119,10 +107,7 @@ export class ThreadRenameEditorController {
this.nextRenameGenerationId += 1;
try {
const context = await this.resolveNamingContext(threadId);
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
const title = await this.generateTitle(context);
if (!title) throw new Error("Codex did not return a usable thread title.");
const title = await this.host.titleService.generateTitle(threadId);
this.dispatch({ type: "ui/rename-generation-succeeded", generatingState, draft: title });
} catch (error) {
if (renameGenerationStillActive(this.renameState, generatingState)) {
@ -133,30 +118,6 @@ export class ThreadRenameEditorController {
}
}
private async resolveNamingContext(threadId: string): Promise<ThreadTitleContext | null> {
const client = this.host.currentClient();
if (!client) return null;
const context = await findThreadTitleContext({
threadId,
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
});
return (
context ??
(this.state.activeThread.id === threadId
? firstThreadTitleContextFromMessageStreamItems(messageStreamItems(this.state.messageStream))
: null)
);
}
private async generateTitle(context: ThreadTitleContext): Promise<string | null> {
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
const settings = this.host.settings();
return generateThreadTitleWithCodex(settings.codexPath, this.host.vaultPath, context, {
threadNamingModel: settings.threadNamingModel,
threadNamingEffort: settings.threadNamingEffort,
});
}
private clear(): void {
this.dispatch({ type: "ui/rename-cleared" });
}
@ -169,3 +130,7 @@ export class ThreadRenameEditorController {
return this.state.threadList.listedThreads.find((item) => item.id === threadId);
}
}
export function activeThreadRenameTitleContext(state: ChatState, threadId: string): ThreadTitleContext | null {
return state.activeThread.id === threadId ? firstThreadTitleContextFromMessageStreamItems(messageStreamItems(state.messageStream)) : null;
}

View file

@ -1,10 +1,8 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { rollbackThread as rollbackThreadOnAppServer } from "../../../../app-server/services/threads";
import { inheritedForkThreadName } from "../../../../domain/threads/model";
import type { CodexPanelSettings } from "../../../../settings/model";
import type { ArchiveExportAdapter } from "../../../../app-server/services/thread-archive-markdown";
import { archiveThreadOnAppServer } from "../../../../app-server/services/thread-archive";
import { renameThreadOnAppServer, threadRenameFromValue, type ThreadRename } from "../../../../app-server/services/thread-rename";
import { threadRenameFromValue } from "../../../../app-server/services/thread-rename";
import type { ThreadOperations } from "../../../threads/thread-operations";
import {
archivedSourceOpenForkFailedMessage,
finishBeforeArchivingThreadsMessage,
@ -29,21 +27,16 @@ import { resumedThreadActionFromActiveRuntime } from "./resume";
export interface ThreadManagementActionsHost {
stateStore: ChatStateStore;
vaultPath: string;
settings: () => CodexPanelSettings;
archiveAdapter: () => ArchiveExportAdapter;
operations: Pick<ThreadOperations, "archiveThread" | "renameThread">;
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
addSystemMessage: (text: string) => void;
showNotice: (text: string) => void;
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshThreads: () => Promise<void>;
refreshSharedThreadListFromOpenSurface: () => void;
refreshAfterThreadMutation: () => Promise<void>;
}
export interface ThreadManagementActions {
@ -55,16 +48,6 @@ export interface ThreadManagementActions {
rollbackThread: (threadId: string) => Promise<void>;
}
type RenameThreadHost = Pick<
ThreadManagementActionsHost,
"ensureConnected" | "currentClient" | "stateStore" | "addSystemMessage" | "notifyThreadRenamed"
>;
type ConnectedRenameThreadHost = Pick<
ThreadManagementActionsHost,
"currentClient" | "stateStore" | "addSystemMessage" | "notifyThreadRenamed"
>;
export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions {
return {
compactThread: (threadId) => compactThread(host, threadId),
@ -91,39 +74,18 @@ async function compactThread(host: ThreadManagementActionsHost, threadId: string
}
}
async function archiveThread(
host: ThreadManagementActionsHost,
threadId: string,
saveMarkdown = host.settings().archiveExportEnabled,
): Promise<void> {
if (await archiveThreadFromPanel(host, threadId, saveMarkdown)) {
host.notifyThreadArchived(threadId);
}
async function archiveThread(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise<void> {
await archiveThreadFromPanel(host, threadId, saveMarkdown);
}
async function archiveThreadFromPanel(
host: ThreadManagementActionsHost,
threadId: string,
saveMarkdown = host.settings().archiveExportEnabled,
): Promise<boolean> {
async function archiveThreadFromPanel(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise<boolean> {
if (chatTurnBusy(threadManagementState(host))) {
host.addSystemMessage(finishBeforeArchivingThreadsMessage());
return false;
}
await host.ensureConnected();
const client = host.currentClient();
if (!client) return false;
try {
const result = await archiveThreadOnAppServer(client, threadId, {
settings: host.settings(),
vaultPath: host.vaultPath,
archiveAdapter: host.archiveAdapter,
saveMarkdown,
});
if (result.exportedPath) {
host.showNotice(`Saved archived thread to ${result.exportedPath}.`);
}
return true;
const options = saveMarkdown === undefined ? {} : { saveMarkdown };
return Boolean(await host.operations.archiveThread(threadId, options));
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
@ -167,8 +129,7 @@ async function forkThreadFromTurn(
try {
const rename = threadRenameFromValue(sourceName);
if (!rename) return;
await renameThreadOnAppServer(client, forkedThreadId, rename);
host.notifyThreadRenamed(forkedThreadId, sourceName);
await host.operations.renameThread(forkedThreadId, rename.name);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(forkNameCopyFailedMessage(forkedThreadId, message));
@ -182,7 +143,6 @@ async function forkThreadFromTurn(
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(archivedSourceOpenForkFailedMessage(threadId, forkedThreadId, message));
}
host.notifyThreadArchived(threadId);
return;
}
try {
@ -196,26 +156,18 @@ async function forkThreadFromTurn(
}
}
async function renameThread(host: RenameThreadHost, threadId: string, value: string): Promise<boolean> {
async function renameThread(host: ThreadManagementActionsHost, threadId: string, value: string): Promise<boolean> {
const rename = threadRenameFromValue(value);
if (!rename) return false;
await host.ensureConnected();
return renameConnectedThread(host, threadId, rename);
}
export async function renameConnectedThread(host: ConnectedRenameThreadHost, threadId: string, rename: ThreadRename): Promise<boolean> {
const client = host.currentClient();
if (!client) return false;
try {
const result = await renameThreadOnAppServer(client, threadId, rename);
const result = await host.operations.renameThread(threadId, rename.name);
if (!result) return false;
const { name } = result;
host.stateStore.dispatch({
type: "thread-list/applied",
threads: host.stateStore.getState().threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)),
});
host.notifyThreadRenamed(threadId, name);
return true;
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
@ -261,8 +213,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
host.addSystemMessage(rollbackCompletedMessage());
host.setStatus(STATUS_ROLLBACK_COMPLETE);
host.notifyActiveThreadIdentityChanged();
await host.refreshThreads();
host.refreshSharedThreadListFromOpenSurface();
await host.refreshAfterThreadMutation();
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
host.setStatus(STATUS_ROLLBACK_FAILED);

View file

@ -1,8 +1,7 @@
import { Notice } from "obsidian";
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { Thread } from "../../../domain/threads/model";
import type { ThreadSurfaceBroadcaster } from "../application/ports/chat-host";
import type { ThreadCatalogFacade } from "../application/ports/chat-host";
import type { ChatConnectionWorkTracker, ChatViewDeferredTasks } from "../application/lifecycle";
import { ChatConnectionController, handleChatConnectionExit } from "../application/connection/connection-controller";
import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } from "../app-server/actions/diagnostics";
@ -42,10 +41,10 @@ export interface ChatConnectionBundleContext {
vaultPath: string;
connectionWork: ChatConnectionWorkTracker;
deferredTasks: ChatViewDeferredTasks;
threadSurfaces: ThreadSurfaceBroadcaster;
sharedCache: {
refreshThreadList: (fetchThreads: () => Promise<readonly Thread[]>) => Promise<readonly Thread[]>;
};
threadCatalog: Pick<
ThreadCatalogFacade,
"applyThreads" | "publishAppServerMetadata" | "refreshThreads" | "notifyThreadArchived" | "notifyThreadRenamed"
>;
goalSync: ThreadGoalSyncActions;
autoTitle: AutoTitleController;
status: ChatConnectionBundleStatus;
@ -57,15 +56,14 @@ export interface ChatConnectionBundleContext {
}
export function createChatConnectionBundle(context: ChatConnectionBundleContext): ChatConnectionBundle {
const { connection, stateStore, vaultPath, connectionWork, deferredTasks, threadSurfaces, sharedCache, goalSync, autoTitle, status } =
context;
const { connection, stateStore, vaultPath, connectionWork, deferredTasks, threadCatalog, goalSync, autoTitle, status } = context;
const currentClient = () => connection.currentClient();
const serverMetadata = createChatServerMetadataActions({
stateStore,
vaultPath,
currentClient,
publishAppServerMetadata: (metadata) => {
threadSurfaces.publishAppServerMetadata(metadata);
threadCatalog.publishAppServerMetadata(metadata);
},
});
const serverDiagnostics = createChatServerDiagnosticsActions({
@ -73,7 +71,7 @@ export function createChatConnectionBundle(context: ChatConnectionBundleContext)
vaultPath,
currentClient,
publishAppServerMetadata: (metadata) => {
threadSurfaces.publishAppServerMetadata(metadata);
threadCatalog.publishAppServerMetadata(metadata);
},
serverMetadataSnapshot: () => serverMetadata.serverMetadataSnapshot(),
});
@ -83,14 +81,14 @@ export function createChatConnectionBundle(context: ChatConnectionBundleContext)
currentClient,
runtimeSnapshotForState: runtimeSnapshotForChatState,
publishThreadList: (threads) => {
threadSurfaces.applyThreadListSnapshot(threads);
threadCatalog.applyThreads(threads);
},
syncThreadGoal: (threadId) => {
void goalSync.syncThreadGoal(threadId);
},
});
const loadSharedThreadList = async (): Promise<void> => {
const threads = await sharedCache.refreshThreadList(() => serverThreads.loadThreadList());
const threads = await threadCatalog.refreshThreads(() => serverThreads.loadThreadList());
serverThreads.applyThreadList(threads);
};
const serverRequestHost = {
@ -111,10 +109,10 @@ export function createChatConnectionBundle(context: ChatConnectionBundleContext)
autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary);
},
notifyThreadArchived: (threadId) => {
threadSurfaces.notifyThreadArchived(threadId);
threadCatalog.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
threadSurfaces.notifyThreadRenamed(threadId, name);
threadCatalog.notifyThreadRenamed(threadId, name);
},
recordMcpStartupStatus: (name, mcpStatus, message) => {
serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message);

View file

@ -0,0 +1,526 @@
import { Notice, type App, type Component, type EventRef } from "obsidian";
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { ArchiveExportAdapter } from "../../../app-server/services/thread-archive-markdown";
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import type { ChatConnectionPhase, ChatAction, ChatState } from "../application/state/root-reducer";
import type { ChatStateStore } from "../application/state/store";
import type { ChatViewDeferredTasks, ChatConnectionWorkTracker, ChatResumeWorkTracker } from "../application/lifecycle";
import type { ChatConnectionController } from "../application/connection/connection-controller";
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
import { AutoTitleController } from "../application/threads/auto-title-controller";
import { createGoalActions, createThreadGoalSyncActions } from "../application/threads/goal-actions";
import type { HistoryController } from "../application/threads/history-controller";
import type { IdentitySync } from "../application/threads/identity-sync";
import { activeThreadRenameTitleContext, type ThreadRenameEditorController } from "../application/threads/rename-editor-controller";
import type { RestorationController } from "../application/threads/restoration-controller";
import type { ResumeController } from "../application/threads/resume-controller";
import { createThreadParts, createThreadSelectionActions } from "../application/threads/composition";
import type { ComposerSubmitActions } from "../application/conversation/composer-submit-actions";
import { createConversationParts } from "./conversation";
import { createConversationComposer } from "./composer";
import { createChatConnectionBundle, type ChatConnectionBundle } from "./connection-bundle";
import type { ChatComposerController } from "../panel/composer-controller";
import type { ChatPanelComposerSurface, ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/surface/model";
import { chatPanelComposerProjection } from "../panel/surface/composer-projection";
import type { ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll-intent";
import type { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import { createChatPanelGoalSurface } from "../panel/surface/goal-surface";
import { createChatPanelToolbarActions, createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
import type { ToolbarActions } from "../ui/toolbar";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
import { pendingRequestsSignature } from "../domain/pending-requests/signatures";
import { ThreadOperations } from "../../threads/thread-operations";
import { ThreadTitleService } from "../../threads/thread-title-service";
import type { CodexChatHost } from "../application/ports/chat-host";
import { messageStreamItems } from "../application/state/message-stream";
import { threadTitleContextFromMessageStreamItems } from "../application/threads/title-context";
export interface ChatPanelEnvironment {
obsidian: {
app: App;
owner: Component;
viewId: string;
registerEvent: (eventRef: EventRef) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
archiveAdapter: () => ArchiveExportAdapter;
requestWorkspaceLayoutSave: () => void;
};
plugin: CodexChatHost;
view: {
panelRoot: () => HTMLElement | null;
viewWindow: () => Window | null;
containsElement: (element: Element) => boolean;
refreshTabHeader: () => void;
};
}
export interface ChatPanelRuntimeParts {
connection: {
manager: ConnectionManager;
controller: ChatConnectionController;
};
serverActions: {
threads: ChatConnectionBundle["serverActions"]["threads"];
metadata: ChatConnectionBundle["serverActions"]["metadata"];
diagnostics: ChatConnectionBundle["serverActions"]["diagnostics"];
};
thread: {
history: HistoryController;
resume: ResumeController;
restoration: RestorationController;
identity: IdentitySync;
rename: ThreadRenameEditorController;
};
toolbar: {
panels: ToolbarPanelActions;
actions: ToolbarActions;
};
composer: {
controller: ChatComposerController;
submission: ComposerSubmitActions;
};
render: {
messageStreamPresenter: MessageStreamPresenter;
};
surface: {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;
composer: ChatPanelComposerSurface;
};
}
interface ChatPanelRuntimeStatus {
set: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
}
export interface ChatPanelRuntimeContext {
environment: ChatPanelEnvironment;
stateStore: ChatStateStore;
deferredTasks: ChatViewDeferredTasks;
connectionWork: ChatConnectionWorkTracker;
resumeWork: ChatResumeWorkTracker;
messageScrollIntent: ChatMessageScrollIntentState;
state(): ChatState;
dispatch(action: ChatAction): void;
systemItem(text: string): MessageStreamItem;
structuredSystemItem(text: string, details: MessageStreamNoticeSection[]): MessageStreamItem;
opened(): boolean;
closing(): boolean;
startNewThread(): Promise<void>;
invalidateResumeWork(): void;
refreshTabHeader(): void;
refreshLiveState(): void;
deferLiveStateRefresh(): void;
notifyActiveThreadIdentityChanged(): void;
connectionDiagnosticDetails(): MessageStreamNoticeSection[];
modelStatusLines(): string[];
effortStatusLines(): string[];
statusSummaryLines(): string[];
collaborationModeLabel(): string;
}
export function createChatPanelRuntime(context: ChatPanelRuntimeContext): ChatPanelRuntimeParts {
const { environment, stateStore } = context;
const status = createRuntimeStatus(context);
const connection = new ConnectionManager(
() => environment.plugin.settingsRef.settings.codexPath,
environment.plugin.settingsRef.vaultPath,
);
const currentClient = () => connection.currentClient();
let connectionController: ChatConnectionController;
const threadOperations = new ThreadOperations({
connection: {
ensureConnected: () => connectionController.ensureConnected(),
currentClient,
},
settings: {
current: () => environment.plugin.settingsRef.settings,
vaultPath: environment.plugin.settingsRef.vaultPath,
},
archiveAdapter: environment.obsidian.archiveAdapter,
catalog: {
notifyThreadArchived: (threadId) => {
environment.plugin.threadCatalog.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
environment.plugin.threadCatalog.notifyThreadRenamed(threadId, name);
},
refreshFromOpenSurface: () => {
environment.plugin.threadCatalog.refreshFromOpenSurface();
},
},
notice: (text) => {
new Notice(text);
},
});
const titleService = new ThreadTitleService({
settings: {
current: () => environment.plugin.settingsRef.settings,
vaultPath: environment.plugin.settingsRef.vaultPath,
},
currentClient,
visibleContext: (threadId) => activeThreadRenameTitleContext(context.state(), threadId),
visibleCompletedTurnContext: (turnId) =>
threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(context.state().messageStream)),
});
const autoTitle = new AutoTitleController({
stateStore,
operations: threadOperations,
titleService,
});
const goalSync = createThreadGoalSyncActions({
stateStore,
currentClient,
addSystemMessage: (text) => {
status.addSystemMessage(text);
},
addGoalEvent: (item) => {
context.dispatch({ type: "message-stream/item-upserted", item });
},
refreshLiveState: () => {
context.refreshLiveState();
},
});
const serverParts = createChatConnectionBundle({
connection,
stateStore,
vaultPath: environment.plugin.settingsRef.vaultPath,
connectionWork: context.connectionWork,
deferredTasks: context.deferredTasks,
threadCatalog: environment.plugin.threadCatalog,
goalSync,
autoTitle,
status,
invalidateResumeWork: () => {
context.invalidateResumeWork();
},
refreshTabHeader: () => {
context.refreshTabHeader();
},
refreshLiveState: () => {
context.refreshLiveState();
},
deferLiveStateRefresh: () => {
context.deferLiveStateRefresh();
},
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath,
});
const {
connection: { controller },
inboundController,
} = serverParts;
connectionController = controller;
const { threads: serverThreads, diagnostics: serverDiagnostics } = serverParts.serverActions;
const ensureConnected = () => connectionController.ensureConnected();
const refreshThreads = () => connectionController.refreshThreads();
const runtimeSettings = createChatRuntimeSettingsActions({
stateStore,
currentClient,
runtimeSnapshotForState: runtimeSnapshotForChatState,
collaborationModeLabel: () => context.collaborationModeLabel(),
addSystemMessage: (text) => {
status.addSystemMessage(text);
},
});
const goals = createGoalActions({
stateStore,
currentClient,
ensureConnected,
addSystemMessage: (text) => {
status.addSystemMessage(text);
},
addGoalEvent: (item) => {
context.dispatch({ type: "message-stream/item-upserted", item });
},
refreshLiveState: () => {
context.refreshLiveState();
},
});
const threadParts = createThreadParts({
settingsRef: environment.plugin.settingsRef,
workspace: environment.plugin.workspace,
threadCatalog: environment.plugin.threadCatalog,
state: {
stateStore,
},
lifecycle: {
deferredTasks: context.deferredTasks,
resumeWork: context.resumeWork,
getOpened: () => context.opened(),
getClosing: () => context.closing(),
},
client: {
getClient: currentClient,
ensureConnected,
},
status,
thread: {
refreshThreads,
notifyIdentityChanged: () => {
context.notifyActiveThreadIdentityChanged();
},
refreshTabHeader: () => {
context.refreshTabHeader();
},
},
liveState: {
refresh: () => {
context.refreshLiveState();
},
},
scroll: {
preservePosition: () => {
context.messageScrollIntent.preservePosition();
},
forceBottom: () => {
context.messageScrollIntent.forceBottom();
},
},
goals,
autoTitle,
operations: threadOperations,
titleService,
});
const { history, identity, restoration, resume, rename } = threadParts;
const composerSurface: ChatPanelComposerSurface = {
thread: {
restoredPlaceholder: () => restoration.placeholder(),
},
runtime: {
requestModel: (model) => runtimeSettings.requestModelFromUi(model),
requestReasoningEffort: (effort) => runtimeSettings.requestReasoningEffortFromUi(effort),
},
};
const composer = createConversationComposer(
{
app: environment.obsidian.app,
settingsRef: environment.plugin.settingsRef,
stateStore,
viewId: environment.obsidian.viewId,
surface: {
composerProjection: (state) => chatPanelComposerProjection(composerSurface, state),
},
liveState: {
refresh: () => {
context.refreshLiveState();
},
},
},
{
runtimeSettings,
},
);
const threadActions = threadParts.createManagementActions({
setText: (text) => {
composer.controller.setDraft(text, { focus: true });
},
});
const toolbarPanels = createToolbarPanelActions({
stateStore,
threadActions,
});
const selection = createThreadSelectionActions(
{
workspace: environment.plugin.workspace,
state: {
stateStore,
},
thread: {
resumeThread: (threadId) => resume.resumeThread(threadId),
},
status,
},
{
closeForThreadSelection: () => {
toolbarPanels.closeForThreadSelection();
},
},
);
const reconnectHost: ChatReconnectActionsHost = {
stateStore,
invalidateConnectionWork: () => {
context.connectionWork.invalidate();
},
invalidateResumeWork: () => {
context.invalidateResumeWork();
},
clearDeferredDiagnostics: () => {
context.deferredTasks.clearDiagnostics();
},
reconnect: () => {
connection.reconnect();
},
setStatus: (statusText, phase) => {
status.set(statusText, phase);
},
ensureConnected,
resumeThread: (threadId) => resume.resumeThread(threadId),
addSystemMessage: (text) => {
status.addSystemMessage(text);
},
};
const reconnect = () => reconnectPanel(reconnectHost);
const toolbarActions = createChatPanelToolbarActions(
{
stateStore,
startNewThread: () => context.startNewThread(),
},
{
connectionController,
reconnectPanel: reconnect,
inboundController,
threadActions,
toolbarPanels,
rename,
selection,
},
);
const toolbarSurface: ChatPanelToolbarSurface = {
state: {
connected: () => connection.isConnected(),
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => environment.plugin.settingsRef.vaultPath,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath,
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled,
},
};
const goalSurface = createChatPanelGoalSurface(
{
settings: environment.plugin.settingsRef.settings,
stateStore,
},
{
connectionController,
inboundController,
threadStarter: serverThreads,
goals,
},
);
const conversationParts = createConversationParts(
{
obsidian: {
app: environment.obsidian.app,
owner: environment.obsidian.owner,
viewId: environment.obsidian.viewId,
},
settingsRef: environment.plugin.settingsRef,
workspace: environment.plugin.workspace,
state: {
stateStore,
},
composer,
lifecycle: {
messageScrollIntent: context.messageScrollIntent,
},
surface: {
pendingRequestsSignature: () =>
pendingRequestsSignature(
context.state().requests.approvals,
context.state().requests.pendingUserInputs,
context.state().requests.userInputDrafts,
),
},
runtime: {
connectionDiagnosticDetails: () => context.connectionDiagnosticDetails(),
modelStatusLines: () => context.modelStatusLines(),
effortStatusLines: () => context.effortStatusLines(),
statusSummaryLines: () => context.statusSummaryLines(),
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
},
liveState: {
refresh: () => {
context.refreshLiveState();
},
},
client: {
getClient: currentClient,
ensureConnected,
},
status,
thread: {
ensureRestoredThreadLoaded: () => restoration.ensureLoaded((threadId) => resume.resumeThread(threadId)),
startNewThread: () => context.startNewThread(),
selectThread: (threadId) => selection.selectThread(threadId),
notifyIdentityChanged: () => {
context.notifyActiveThreadIdentityChanged();
},
resetTurnPresence: (hadTurns) => {
autoTitle.resetThreadTurnPresence(hadTurns);
},
},
scroll: {
forceBottom: () => {
context.messageScrollIntent.forceBottom();
},
followBottom: () => {
context.messageScrollIntent.followBottom();
},
},
},
{
controller: inboundController,
threadStarter: serverThreads,
runtimeSettings,
threadActions,
reconnectPanel: reconnect,
goals,
history,
},
);
const { composerSubmit, messageStreamPresenter } = conversationParts;
const composerController = composer.controller;
return {
connection: {
manager: connection,
controller: connectionController,
},
serverActions: serverParts.serverActions,
thread: {
history,
resume,
restoration,
identity,
rename,
},
toolbar: {
panels: toolbarPanels,
actions: toolbarActions,
},
composer: {
controller: composerController,
submission: composerSubmit,
},
render: {
messageStreamPresenter,
},
surface: {
toolbar: toolbarSurface,
goal: goalSurface,
composer: composerSurface,
},
};
}
function createRuntimeStatus(context: ChatPanelRuntimeContext): ChatPanelRuntimeStatus {
return {
set: (statusText, phase) => {
context.dispatch({ type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });
},
addSystemMessage: (text) => {
context.dispatch({ type: "message-stream/system-item-added", item: context.systemItem(text) });
},
addStructuredSystemMessage: (text, details) => {
context.dispatch({ type: "message-stream/system-item-added", item: context.structuredSystemItem(text, details) });
},
};
}

View file

@ -1,20 +1,10 @@
import { Notice, type App, type Component, type EventRef } from "obsidian";
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { Thread } from "../../../domain/threads/model";
import { getThreadTitle } from "../../../domain/threads/model";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import { shortThreadId } from "../../../utils";
import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot";
import type { ArchiveExportAdapter } from "../../../app-server/services/thread-archive-markdown";
import type { CodexChatHost } from "../application/ports/chat-host";
import type { ChatConnectionController } from "../application/connection/connection-controller";
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
import type { ChatComposerController } from "../panel/composer-controller";
import { createConversationParts } from "./conversation";
import { createConversationComposer } from "./composer";
import type { ComposerSubmitActions } from "../application/conversation/composer-submit-actions";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../domain/local-id";
@ -25,93 +15,16 @@ import {
} from "../presentation/runtime/status";
import { createChatViewDeferredTasks } from "./lifecycle";
import { ChatConnectionWorkTracker, ChatResumeWorkTracker, type ChatViewDeferredTasks } from "../application/lifecycle";
import { createChatPanelToolbarActions, createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
import { connectionDiagnosticsModel } from "../panel/surface/toolbar-projection";
import { openPanelTurnLifecycle, parseRestoredThreadState } from "../panel/snapshot";
import { collaborationModeLabel as formatCollaborationModeLabel } from "../presentation/runtime/messages";
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import { runtimeSnapshotForChatState, type RuntimeSnapshot } from "../application/runtime/snapshot";
import { chatPanelComposerProjection } from "../panel/surface/composer-projection";
import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll-intent";
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell";
import { chatTurnBusy, type ChatAction, type ChatConnectionPhase, type ChatState } from "../application/state/root-reducer";
import { chatTurnBusy, type ChatAction, type ChatState } from "../application/state/root-reducer";
import { createChatStateStore, type ChatStateStore } from "../application/state/store";
import type { HistoryController } from "../application/threads/history-controller";
import type { IdentitySync } from "../application/threads/identity-sync";
import type { ThreadRenameEditorController } from "../application/threads/rename-editor-controller";
import type { RestorationController } from "../application/threads/restoration-controller";
import type { ResumeController } from "../application/threads/resume-controller";
import { createThreadParts, createThreadSelectionActions } from "../application/threads/composition";
import { createChatConnectionBundle, type ChatConnectionBundle } from "./connection-bundle";
import type { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
import { pendingRequestsSignature } from "../domain/pending-requests/signatures";
import { createChatPanelGoalSurface } from "../panel/surface/goal-surface";
import type { ChatPanelComposerSurface, ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/surface/model";
import type { ToolbarActions } from "../ui/toolbar";
import { AutoTitleController } from "../application/threads/auto-title-controller";
import { createGoalActions, createThreadGoalSyncActions } from "../application/threads/goal-actions";
export interface ChatPanelEnvironment {
obsidian: {
app: App;
owner: Component;
viewId: string;
registerEvent: (eventRef: EventRef) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
archiveAdapter: () => ArchiveExportAdapter;
requestWorkspaceLayoutSave: () => void;
};
plugin: CodexChatHost;
view: {
panelRoot: () => HTMLElement | null;
viewWindow: () => Window | null;
containsElement: (element: Element) => boolean;
refreshTabHeader: () => void;
};
}
interface ChatPanelSessionParts {
connection: {
manager: ConnectionManager;
controller: ChatConnectionController;
};
serverActions: {
threads: ChatConnectionBundle["serverActions"]["threads"];
metadata: ChatConnectionBundle["serverActions"]["metadata"];
diagnostics: ChatConnectionBundle["serverActions"]["diagnostics"];
};
thread: {
history: HistoryController;
resume: ResumeController;
restoration: RestorationController;
identity: IdentitySync;
rename: ThreadRenameEditorController;
};
toolbar: {
panels: ToolbarPanelActions;
actions: ToolbarActions;
};
composer: {
controller: ChatComposerController;
submission: ComposerSubmitActions;
};
render: {
messageStreamPresenter: MessageStreamPresenter;
};
surface: {
toolbar: ChatPanelToolbarSurface;
goal: ChatPanelGoalSurface;
composer: ChatPanelComposerSurface;
};
}
interface ChatSessionSideEffects {
status: {
set: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
};
}
import type { ChatSurfaceHandle } from "./surface-handle";
import { createChatPanelRuntime, type ChatPanelEnvironment, type ChatPanelRuntimeParts } from "./runtime";
interface ChatPanelWarmupHost {
deferredTasks: ChatViewDeferredTasks;
@ -140,9 +53,9 @@ function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly
return title ? `Codex: ${title}` : "Codex";
}
export class ChatPanelSession {
export class ChatPanelSession implements ChatSurfaceHandle {
private readonly stateStore: ChatStateStore = createChatStateStore();
private readonly parts: ChatPanelSessionParts;
private readonly parts: ChatPanelRuntimeParts;
private readonly deferredTasks: ChatViewDeferredTasks;
private readonly connectionWork = new ChatConnectionWorkTracker();
@ -154,7 +67,43 @@ export class ChatPanelSession {
constructor(private readonly environment: ChatPanelEnvironment) {
this.deferredTasks = createChatViewDeferredTasks(() => this.viewWindow());
this.parts = this.createSessionParts();
this.parts = createChatPanelRuntime({
environment,
stateStore: this.stateStore,
deferredTasks: this.deferredTasks,
connectionWork: this.connectionWork,
resumeWork: this.resumeWork,
messageScrollIntent: this.messageScrollIntent,
state: () => this.state,
dispatch: (action) => {
this.dispatch(action);
},
systemItem: (text) => this.systemItem(text),
structuredSystemItem: (text, details) => this.structuredSystemItem(text, details),
opened: () => this.opened,
closing: () => this.closing,
startNewThread: () => this.startNewThread(),
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
refreshLiveState: () => {
this.refreshLiveState();
},
deferLiveStateRefresh: () => {
this.deferLiveStateRefresh();
},
notifyActiveThreadIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
modelStatusLines: () => this.modelStatusLines(),
effortStatusLines: () => this.effortStatusLines(),
statusSummaryLines: () => this.statusSummaryLines(),
collaborationModeLabel: () => this.collaborationModeLabel(),
});
}
private get state(): ChatState {
@ -300,377 +249,10 @@ export class ChatPanelSession {
this.focusComposer();
}
private createSessionParts(): ChatPanelSessionParts {
const sideEffects = this.createSideEffects();
const connection = new ConnectionManager(
() => this.environment.plugin.settingsRef.settings.codexPath,
this.environment.plugin.settingsRef.vaultPath,
);
const currentClient = () => connection.currentClient();
const autoTitle = new AutoTitleController({
stateStore: this.stateStore,
vaultPath: this.environment.plugin.settingsRef.vaultPath,
settings: () => this.environment.plugin.settingsRef.settings,
currentClient,
notifyThreadRenamed: (threadId, name) => {
this.environment.plugin.threadSurfaces.notifyThreadRenamed(threadId, name);
},
});
const goalSync = createThreadGoalSyncActions({
stateStore: this.stateStore,
currentClient,
addSystemMessage: sideEffects.status.addSystemMessage,
addGoalEvent: (item) => {
this.dispatch({ type: "message-stream/item-upserted", item });
},
refreshLiveState: () => {
this.refreshLiveState();
},
});
const serverParts = createChatConnectionBundle({
connection,
stateStore: this.stateStore,
vaultPath: this.environment.plugin.settingsRef.vaultPath,
connectionWork: this.connectionWork,
deferredTasks: this.deferredTasks,
threadSurfaces: this.environment.plugin.threadSurfaces,
sharedCache: this.environment.plugin.sharedCache,
goalSync,
autoTitle,
status: sideEffects.status,
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
refreshLiveState: () => {
this.refreshLiveState();
},
deferLiveStateRefresh: () => {
this.deferLiveStateRefresh();
},
configuredCommand: () => this.environment.plugin.settingsRef.settings.codexPath,
});
const {
connection: { controller: connectionController },
inboundController,
} = serverParts;
const { threads: serverThreads, diagnostics: serverDiagnostics } = serverParts.serverActions;
const ensureConnected = () => connectionController.ensureConnected();
const refreshThreads = () => connectionController.refreshThreads();
const runtimeSettings = createChatRuntimeSettingsActions({
stateStore: this.stateStore,
currentClient,
runtimeSnapshotForState: runtimeSnapshotForChatState,
collaborationModeLabel: () => this.collaborationModeLabel(),
addSystemMessage: sideEffects.status.addSystemMessage,
});
const goals = createGoalActions({
stateStore: this.stateStore,
currentClient,
ensureConnected,
addSystemMessage: sideEffects.status.addSystemMessage,
addGoalEvent: (item) => {
this.dispatch({ type: "message-stream/item-upserted", item });
},
refreshLiveState: () => {
this.refreshLiveState();
},
});
const threadParts = createThreadParts({
obsidian: {
archiveAdapter: this.environment.obsidian.archiveAdapter,
},
settingsRef: this.environment.plugin.settingsRef,
workspace: this.environment.plugin.workspace,
threadSurfaces: this.environment.plugin.threadSurfaces,
state: {
stateStore: this.stateStore,
},
lifecycle: {
deferredTasks: this.deferredTasks,
resumeWork: this.resumeWork,
getOpened: () => this.opened,
getClosing: () => this.closing,
},
client: {
getClient: currentClient,
ensureConnected,
},
status: sideEffects.status,
notify: {
showNotice: (text) => {
new Notice(text);
},
},
thread: {
refreshThreads,
notifyIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
},
liveState: {
refresh: () => {
this.refreshLiveState();
},
},
scroll: {
preservePosition: () => {
this.messageScrollIntent.preservePosition();
},
forceBottom: () => {
this.messageScrollIntent.forceBottom();
},
},
goals,
autoTitle,
});
const { history, identity, restoration, resume, rename } = threadParts;
const composerSurface: ChatPanelComposerSurface = {
thread: {
restoredPlaceholder: () => restoration.placeholder(),
},
runtime: {
requestModel: (model) => runtimeSettings.requestModelFromUi(model),
requestReasoningEffort: (effort) => runtimeSettings.requestReasoningEffortFromUi(effort),
},
};
const composer = createConversationComposer(
{
app: this.environment.obsidian.app,
settingsRef: this.environment.plugin.settingsRef,
stateStore: this.stateStore,
viewId: this.environment.obsidian.viewId,
surface: {
composerProjection: (state) => chatPanelComposerProjection(composerSurface, state),
},
liveState: {
refresh: () => {
this.refreshLiveState();
},
},
},
{
runtimeSettings,
},
);
const threadActions = threadParts.createManagementActions({
setText: (text) => {
composer.controller.setDraft(text, { focus: true });
},
});
const toolbarPanels = createToolbarPanelActions({
stateStore: this.stateStore,
threadActions,
});
const selection = createThreadSelectionActions(
{
workspace: this.environment.plugin.workspace,
state: {
stateStore: this.stateStore,
},
thread: {
resumeThread: (threadId) => resume.resumeThread(threadId),
},
status: sideEffects.status,
},
{
closeForThreadSelection: () => {
toolbarPanels.closeForThreadSelection();
},
},
);
const reconnectHost: ChatReconnectActionsHost = {
stateStore: this.stateStore,
invalidateConnectionWork: () => {
this.connectionWork.invalidate();
},
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
clearDeferredDiagnostics: () => {
this.deferredTasks.clearDiagnostics();
},
reconnect: () => {
connection.reconnect();
},
setStatus: sideEffects.status.set,
ensureConnected,
resumeThread: (threadId) => resume.resumeThread(threadId),
addSystemMessage: sideEffects.status.addSystemMessage,
};
const reconnect = () => reconnectPanel(reconnectHost);
const toolbarActions = createChatPanelToolbarActions(
{
stateStore: this.stateStore,
startNewThread: () => this.startNewThread(),
},
{
connectionController,
reconnectPanel: reconnect,
inboundController,
threadActions,
toolbarPanels,
rename,
selection,
},
);
const toolbarSurface: ChatPanelToolbarSurface = {
state: {
connected: () => connection.isConnected(),
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => this.environment.plugin.settingsRef.vaultPath,
configuredCommand: () => this.environment.plugin.settingsRef.settings.codexPath,
archiveExportEnabled: () => this.environment.plugin.settingsRef.settings.archiveExportEnabled,
},
};
const goalSurface = createChatPanelGoalSurface(
{
settings: this.environment.plugin.settingsRef.settings,
stateStore: this.stateStore,
},
{
connectionController,
inboundController,
threadStarter: serverThreads,
goals,
},
);
const conversationParts = createConversationParts(
{
obsidian: {
app: this.environment.obsidian.app,
owner: this.environment.obsidian.owner,
viewId: this.environment.obsidian.viewId,
},
settingsRef: this.environment.plugin.settingsRef,
workspace: this.environment.plugin.workspace,
state: {
stateStore: this.stateStore,
},
composer,
lifecycle: {
messageScrollIntent: this.messageScrollIntent,
},
surface: {
pendingRequestsSignature: () =>
pendingRequestsSignature(
this.state.requests.approvals,
this.state.requests.pendingUserInputs,
this.state.requests.userInputDrafts,
),
},
runtime: {
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
modelStatusLines: () => this.modelStatusLines(),
effortStatusLines: () => this.effortStatusLines(),
statusSummaryLines: () => this.statusSummaryLines(),
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
},
liveState: {
refresh: () => {
this.refreshLiveState();
},
},
client: {
getClient: currentClient,
ensureConnected,
},
status: sideEffects.status,
thread: {
ensureRestoredThreadLoaded: () => restoration.ensureLoaded((threadId) => resume.resumeThread(threadId)),
startNewThread: () => this.startNewThread(),
selectThread: (threadId) => selection.selectThread(threadId),
notifyIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
resetTurnPresence: (hadTurns) => {
autoTitle.resetThreadTurnPresence(hadTurns);
},
},
scroll: {
forceBottom: () => {
this.messageScrollIntent.forceBottom();
},
followBottom: () => {
this.messageScrollIntent.followBottom();
},
},
},
{
controller: inboundController,
threadStarter: serverThreads,
runtimeSettings,
threadActions,
reconnectPanel: reconnect,
goals,
history,
},
);
const { composerSubmit, messageStreamPresenter } = conversationParts;
const composerController = composer.controller;
return {
connection: {
manager: connection,
controller: connectionController,
},
serverActions: serverParts.serverActions,
thread: {
history,
resume,
restoration,
identity,
rename,
},
toolbar: {
panels: toolbarPanels,
actions: toolbarActions,
},
composer: {
controller: composerController,
submission: composerSubmit,
},
render: {
messageStreamPresenter,
},
surface: {
toolbar: toolbarSurface,
goal: goalSurface,
composer: composerSurface,
},
};
}
private createSideEffects(): ChatSessionSideEffects {
return {
status: {
set: (statusText, phase) => {
this.dispatch({ type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });
},
addSystemMessage: (text) => {
this.dispatch({ type: "message-stream/system-item-added", item: this.systemItem(text) });
},
addStructuredSystemMessage: (text, details) => {
this.dispatch({ type: "message-stream/system-item-added", item: this.structuredSystemItem(text, details) });
},
},
};
}
private applyCachedAppServerState(): void {
const threads = this.environment.plugin.sharedCache.cachedThreadList();
const threads = this.environment.plugin.threadCatalog.cachedThreads();
if (threads) this.parts.serverActions.threads.applyThreadList(threads);
const metadata = this.environment.plugin.sharedCache.cachedAppServerMetadata();
const metadata = this.environment.plugin.threadCatalog.cachedAppServerMetadata();
if (metadata) this.parts.serverActions.metadata.applyAppServerMetadata(metadata);
}
@ -713,7 +295,7 @@ export class ChatPanelSession {
}
private async loadSharedThreadList(): Promise<void> {
const threads = await this.environment.plugin.sharedCache.refreshThreadList(() => this.parts.serverActions.threads.loadThreadList());
const threads = await this.environment.plugin.threadCatalog.refreshThreads(() => this.parts.serverActions.threads.loadThreadList());
this.parts.serverActions.threads.applyThreadList(threads);
}
@ -727,7 +309,7 @@ export class ChatPanelSession {
}
private refreshLiveState(): void {
this.environment.plugin.threadSurfaces.refreshThreadsViewLiveState();
this.environment.plugin.threadCatalog.refreshThreadsViewLiveState();
}
private deferLiveStateRefresh(): void {

View file

@ -0,0 +1,26 @@
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { Thread } from "../../../domain/threads/model";
import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot";
export interface ChatSurfaceHandle {
displayTitle(): string;
persistedState(): Record<string, unknown>;
applyViewState(state: unknown): void;
open(): void;
close(): void;
refreshSettings(): void;
refreshSharedThreadList(): Promise<void>;
applyThreadListSnapshot(threads: readonly Thread[]): void;
applyAppServerMetadataSnapshot(metadata: SharedServerMetadata): void;
applyAvailableModelsSnapshot(models: readonly ModelMetadata[]): void;
openPanelSnapshot(): OpenCodexPanelSnapshot;
openThread(threadId: string): Promise<void>;
focusThread(threadId?: string | null): Promise<void>;
focusComposer(): void;
notifyThreadArchived(threadId: string): void;
notifyThreadRenamed(threadId: string, name: string | null): void;
setComposerText(text: string): void;
connect(): Promise<void>;
startNewThread(): Promise<void>;
}

View file

@ -1,20 +1,17 @@
import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { Thread } from "../../../domain/threads/model";
import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { CodexChatHost } from "../application/ports/chat-host";
import { ChatPanelSession } from "./session";
import type { ChatSurfaceHandle } from "./surface-handle";
export class CodexChatView extends ItemView {
private readonly session: ChatPanelSession;
readonly surface: ChatSurfaceHandle;
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
constructor(leaf: WorkspaceLeaf, plugin: CodexChatHost) {
super(leaf);
this.session = new ChatPanelSession({
this.surface = new ChatPanelSession({
obsidian: {
app: this.app,
owner: this,
@ -47,7 +44,7 @@ export class CodexChatView extends ItemView {
}
override getDisplayText(): string {
return this.session.displayTitle();
return this.surface.displayTitle();
}
override getIcon(): string {
@ -55,76 +52,20 @@ export class CodexChatView extends ItemView {
}
override getState(): Record<string, unknown> {
return this.session.persistedState();
return this.surface.persistedState();
}
override async setState(state: unknown, result: ViewStateResult): Promise<void> {
await super.setState(state, result);
this.session.applyViewState(state);
this.surface.applyViewState(state);
}
override async onOpen(): Promise<void> {
this.session.open();
this.surface.open();
}
override async onClose(): Promise<void> {
this.session.close();
}
refreshSettings(): void {
this.session.refreshSettings();
}
refreshSharedThreadList(): Promise<void> {
return this.session.refreshSharedThreadList();
}
applyThreadListSnapshot(threads: readonly Thread[]): void {
this.session.applyThreadListSnapshot(threads);
}
applyAppServerMetadataSnapshot(metadata: SharedServerMetadata): void {
this.session.applyAppServerMetadataSnapshot(metadata);
}
applyAvailableModelsSnapshot(models: readonly ModelMetadata[]): void {
this.session.applyAvailableModelsSnapshot(models);
}
openPanelSnapshot(): OpenCodexPanelSnapshot {
return this.session.openPanelSnapshot();
}
openThread(threadId: string): Promise<void> {
return this.session.openThread(threadId);
}
focusThread(threadId: string | null = null): Promise<void> {
return this.session.focusThread(threadId);
}
focusComposer(): void {
this.session.focusComposer();
}
notifyThreadArchived(threadId: string): void {
this.session.notifyThreadArchived(threadId);
}
notifyThreadRenamed(threadId: string, name: string | null): void {
this.session.notifyThreadRenamed(threadId, name);
}
setComposerText(text: string): void {
this.session.setComposerText(text);
}
connect(): Promise<void> {
return this.session.connect();
}
startNewThread(): Promise<void> {
return this.session.startNewThread();
this.surface.close();
}
private refreshTabHeader(): void {

View file

@ -11,8 +11,10 @@ export interface ThreadPickerHost {
readonly app: App;
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
cachedThreadList(): readonly Thread[] | null;
refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
readonly threadCatalog: {
cachedThreads(): readonly Thread[] | null;
refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
};
openThreadInCurrentView(threadId: string): Promise<void>;
openThreadInAvailableView(threadId: string): Promise<void>;
}
@ -76,14 +78,14 @@ function threadOpenModeFromEvent(evt: MouseEvent | KeyboardEvent): ThreadOpenMod
}
async function loadThreadPickerThreads(host: ThreadPickerHost): Promise<readonly Thread[]> {
const cached = host.cachedThreadList();
const cached = host.threadCatalog.cachedThreads();
if (cached) return cached;
return withShortLivedAppServerClient(
host.settings.codexPath,
host.vaultPath,
async (client) =>
host.refreshThreadList(async () => {
host.threadCatalog.refreshThreads(async () => {
return listThreads(client, host.vaultPath);
}),
{

View file

@ -2,15 +2,14 @@ import { Notice } from "obsidian";
import type { AppServerClient } from "../../app-server/connection/client";
import { ConnectionManager, type ConnectionManagerHandlers, StaleConnectionError } from "../../app-server/connection/connection-manager";
import { listThreads, readCompletedConversationSummariesPage } from "../../app-server/services/threads";
import { listThreads } from "../../app-server/services/threads";
import type { Thread } from "../../domain/threads/model";
import type { CodexPanelSettings } from "../../settings/model";
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
import { archiveThreadOnAppServer } from "../../app-server/services/thread-archive";
import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown";
import { renameThreadOnAppServer, threadRenameFromValue } from "../../app-server/services/thread-rename";
import { generateThreadTitleWithCodex } from "../../app-server/services/thread-title-generation";
import { findThreadTitleContext, THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE } from "../../domain/threads/title-generation-model";
import { threadRenameFromValue } from "../../app-server/services/thread-rename";
import { ThreadOperations } from "../threads/thread-operations";
import { ThreadTitleService } from "../threads/thread-title-service";
import { renderThreadsView, unmountThreadsView } from "./renderer";
import {
completedThreadAutoNameState,
@ -36,13 +35,16 @@ import {
export interface CodexThreadsHost {
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
readonly threadCatalog: {
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
notifyThreadRenamed(threadId: string, name: string | null): void;
refreshFromOpenSurface(): void;
refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
cachedThreads(): readonly Thread[] | null;
};
openNewPanel(): Promise<unknown>;
openThreadInAvailableView(threadId: string): Promise<void>;
getOpenPanelSnapshots(): OpenCodexPanelSnapshot[];
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
notifyThreadRenamed(threadId: string, name: string | null): void;
refreshThreadList(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]>;
cachedThreadList(): readonly Thread[] | null;
}
export interface CodexThreadsSessionEnvironment {
@ -62,6 +64,8 @@ type ThreadsViewStatus =
export class CodexThreadsSession {
private readonly connection: ConnectionManager;
private readonly operations: ThreadOperations;
private readonly titleService: ThreadTitleService;
private readonly deferredTasks: ThreadsViewDeferredTasks;
private client: AppServerClient | null = null;
private connectionLifecycle: ThreadsViewConnectionLifecycleState = { kind: "idle" };
@ -74,6 +78,38 @@ export class CodexThreadsSession {
constructor(private readonly environment: CodexThreadsSessionEnvironment) {
this.deferredTasks = createThreadsViewDeferredTasks(() => this.viewWindow());
this.connection = new ConnectionManager(() => this.host.settings.codexPath, this.host.vaultPath);
this.operations = new ThreadOperations({
connection: {
ensureConnected: () => this.ensureConnected(),
currentClient: () => this.client,
},
settings: {
current: () => this.host.settings,
vaultPath: this.host.vaultPath,
},
archiveAdapter: () => this.environment.archiveAdapter(),
catalog: {
notifyThreadArchived: (threadId, options) => {
this.host.threadCatalog.notifyThreadArchived(threadId, options);
},
notifyThreadRenamed: (threadId, name) => {
this.host.threadCatalog.notifyThreadRenamed(threadId, name);
},
refreshFromOpenSurface: () => {
this.host.threadCatalog.refreshFromOpenSurface();
},
},
notice: (message) => {
new Notice(message);
},
});
this.titleService = new ThreadTitleService({
settings: {
current: () => this.host.settings,
vaultPath: this.host.vaultPath,
},
currentClient: () => this.client,
});
}
private connectionHandlers(): ConnectionManagerHandlers {
@ -102,7 +138,7 @@ export class CodexThreadsSession {
this.environment.registerPointerDown((event) => {
this.cancelArchiveConfirmOnOutsidePointer(event);
});
const cachedThreads = this.host.cachedThreadList();
const cachedThreads = this.host.threadCatalog.cachedThreads();
if (cachedThreads) {
this.threads = cachedThreads;
}
@ -126,7 +162,7 @@ export class CodexThreadsSession {
try {
await this.ensureConnected();
if (this.isStaleRefresh(refresh) || !this.client) return;
const threads = await this.host.refreshThreadList(async () => {
const threads = await this.host.threadCatalog.refreshThreads(async () => {
if (!this.client) return [];
return listThreads(this.client, this.host.vaultPath);
});
@ -301,10 +337,9 @@ export class CodexThreadsSession {
try {
await this.ensureConnected();
if (this.renameStates.get(threadId) !== editingState) return;
if (!this.client) return;
const result = await renameThreadOnAppServer(this.client, threadId, rename);
const result = await this.operations.renameThread(threadId, rename.name);
if (!result) return;
this.renameStates.delete(threadId);
this.host.notifyThreadRenamed(threadId, result.name);
} catch (error) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
@ -320,18 +355,7 @@ export class CodexThreadsSession {
try {
await this.ensureConnected();
if (this.renameStates.get(threadId) !== generatingState) return;
if (!this.client) return;
const client = this.client;
const context = await findThreadTitleContext({
threadId,
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
});
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
const title = await generateThreadTitleWithCodex(this.host.settings.codexPath, this.host.vaultPath, context, {
threadNamingModel: this.host.settings.threadNamingModel,
threadNamingEffort: this.host.settings.threadNamingEffort,
});
if (!title) throw new Error("Codex did not return a usable thread title.");
const title = await this.titleService.generateTitle(threadId);
const renamedState = generatedThreadAutoNameState(this.renameStates.get(threadId), generatingState, title);
if (renamedState) this.renameStates.set(threadId, renamedState);
} catch (error) {
@ -363,19 +387,13 @@ export class CodexThreadsSession {
private async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
try {
await this.ensureConnected();
if (!this.client) return;
const result = await archiveThreadOnAppServer(this.client, threadId, {
settings: this.host.settings,
vaultPath: this.host.vaultPath,
archiveAdapter: () => this.environment.archiveAdapter(),
const result = await this.operations.archiveThread(threadId, {
saveMarkdown,
closeOpenPanels: true,
});
if (result.exportedPath) {
new Notice(`Saved archived thread to ${result.exportedPath}.`);
}
if (!result) return;
if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null;
this.renameStates.delete(threadId);
this.host.notifyThreadArchived(threadId, { closeOpenPanels: true });
} catch (error) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();

View file

@ -0,0 +1,83 @@
import type { AppServerClient } from "../../app-server/connection/client";
import { archiveThreadOnAppServer, type ArchiveThreadResult } from "../../app-server/services/thread-archive";
import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown";
import { renameThreadOnAppServer, threadRenameFromValue, type ThreadRename } from "../../app-server/services/thread-rename";
import type { CodexPanelSettings } from "../../settings/model";
export interface ThreadOperationsHost {
connection: {
ensureConnected(): Promise<void>;
currentClient(): AppServerClient | null;
};
settings: {
current(): CodexPanelSettings;
vaultPath: string;
};
archiveAdapter(): ArchiveExportAdapter;
catalog: {
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
notifyThreadRenamed(threadId: string, name: string | null): void;
refreshFromOpenSurface(): void;
};
notice(message: string): void;
}
export interface ArchiveThreadOptions {
saveMarkdown?: boolean;
closeOpenPanels?: boolean;
}
export interface RenameThreadResult {
name: string;
}
export interface RenameThreadOptions {
shouldPublish?: () => boolean;
}
export class ThreadOperations {
constructor(private readonly host: ThreadOperationsHost) {}
async renameThread(threadId: string, value: string, options: RenameThreadOptions = {}): Promise<RenameThreadResult | null> {
const rename = threadRenameFromValue(value);
if (!rename) return null;
await this.host.connection.ensureConnected();
return this.renameConnectedThread(threadId, rename, options);
}
private async renameConnectedThread(
threadId: string,
rename: ThreadRename,
options: RenameThreadOptions = {},
): Promise<RenameThreadResult | null> {
const client = this.host.connection.currentClient();
if (!client) return null;
const result = await renameThreadOnAppServer(client, threadId, rename);
if (options.shouldPublish?.() ?? true) {
this.host.catalog.notifyThreadRenamed(threadId, result.name);
}
return { name: result.name };
}
async archiveThread(threadId: string, options: ArchiveThreadOptions = {}): Promise<ArchiveThreadResult | null> {
await this.host.connection.ensureConnected();
const client = this.host.connection.currentClient();
if (!client) return null;
const settings = this.host.settings.current();
const result = await archiveThreadOnAppServer(client, threadId, {
settings,
vaultPath: this.host.settings.vaultPath,
archiveAdapter: () => this.host.archiveAdapter(),
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled,
});
if (result.exportedPath) {
this.host.notice(`Saved archived thread to ${result.exportedPath}.`);
}
const notificationOptions = options.closeOpenPanels === undefined ? undefined : { closeOpenPanels: options.closeOpenPanels };
this.host.catalog.notifyThreadArchived(threadId, notificationOptions);
return result;
}
}

View file

@ -0,0 +1,62 @@
import type { AppServerClient } from "../../app-server/connection/client";
import { generateThreadTitleWithCodex } from "../../app-server/services/thread-title-generation";
import { readCompletedConversationSummariesPage } from "../../app-server/services/threads";
import type { ThreadConversationSummary } from "../../domain/threads/transcript";
import {
findThreadTitleContext,
THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE,
threadTitleContextFromConversationSummary,
type ThreadTitleContext,
} from "../../domain/threads/title-generation-model";
import type { CodexPanelSettings } from "../../settings/model";
export interface ThreadTitleServiceHost {
settings: {
current(): CodexPanelSettings;
vaultPath: string;
};
currentClient(): AppServerClient | null;
visibleContext?(threadId: string): ThreadTitleContext | null;
visibleCompletedTurnContext?(turnId: string): ThreadTitleContext | null;
generateThreadTitle?(context: ThreadTitleContext): Promise<string | null>;
}
export class ThreadTitleService {
constructor(private readonly host: ThreadTitleServiceHost) {}
async generateTitle(threadId: string): Promise<string> {
const context = await this.resolveContext(threadId);
if (!context) throw new Error(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
const title = await this.generate(context);
if (!title) throw new Error("Codex did not return a usable thread title.");
return title;
}
async resolveContext(threadId: string): Promise<ThreadTitleContext | null> {
const client = this.host.currentClient();
const persistedContext = client
? await findThreadTitleContext({
threadId,
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
})
: null;
return persistedContext ?? this.host.visibleContext?.(threadId) ?? null;
}
completedTurnContext(turnId: string, completedSummary: ThreadConversationSummary | null): ThreadTitleContext | null {
return (
this.host.visibleCompletedTurnContext?.(turnId) ??
(completedSummary ? threadTitleContextFromConversationSummary(completedSummary) : null)
);
}
async generate(context: ThreadTitleContext): Promise<string | null> {
if (this.host.generateThreadTitle) return this.host.generateThreadTitle(context);
const settings = this.host.settings.current();
return generateThreadTitleWithCodex(settings.codexPath, this.host.settings.vaultPath, context, {
threadNamingModel: settings.threadNamingModel,
threadNamingEffort: settings.threadNamingEffort,
});
}
}

View file

@ -3,116 +3,80 @@ import { Plugin } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import { registerSelectionRewriteCommand } from "./features/selection-rewrite/command";
import { CodexChatView } from "./features/chat/host/view";
import type { CodexChatHost } from "./features/chat/application/ports/chat-host";
import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view";
import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal";
import { CodexThreadsView, type CodexThreadsHost } from "./features/threads-view/view";
import { SharedAppServerCache } from "./app-server/services/shared-cache";
import type { SharedAppServerCacheContext } from "./app-server/services/shared-cache-state";
import { CodexThreadsView } from "./features/threads-view/view";
import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData, type CodexPanelSettings } from "./settings/model";
import { CodexPanelSettingTab, type CodexPanelSettingTabHost } from "./settings/tab";
import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
import { createThreadSurfaceActions } from "./workspace/thread-surface-actions";
import type { SharedServerMetadata } from "./domain/server/metadata";
import type { Thread } from "./domain/threads/model";
import { CodexPanelSettingTab } from "./settings/tab";
import { CodexPanelRuntime } from "./plugin-runtime";
export default class CodexPanelPlugin extends Plugin {
settings: CodexPanelSettings = DEFAULT_SETTINGS;
vaultPath = "";
private readonly sharedAppServerCache = new SharedAppServerCache();
private readonly panels = new WorkspacePanelCoordinator({
readonly runtime = new CodexPanelRuntime({
app: this.app,
refreshThreadsViewLiveState: () => {
this.threadSurfaces.refreshThreadsViewLiveState();
},
});
private readonly threadSurfaces = createThreadSurfaceActions({
app: this.app,
panels: this.panels,
settingsRef: this,
saveSettings: () => this.saveSettings(),
});
override async onload(): Promise<void> {
this.panels.reset();
this.runtime.reset();
this.vaultPath = getVaultPath(this.app);
await this.loadSettings();
this.registerView(VIEW_TYPE_CODEX_PANEL, (leaf) => new CodexChatView(leaf, this.chatHost()));
this.registerView(VIEW_TYPE_CODEX_PANEL, (leaf) => new CodexChatView(leaf, this.runtime.chatHost()));
this.registerView(VIEW_TYPE_CODEX_TURN_DIFF, (leaf) => new CodexChatTurnDiffView(leaf));
this.registerView(VIEW_TYPE_CODEX_THREADS, (leaf) => new CodexThreadsView(leaf, this.threadsHost()));
this.registerView(VIEW_TYPE_CODEX_THREADS, (leaf) => new CodexThreadsView(leaf, this.runtime.threadsHost()));
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
this.panels.recordLastFocusedPanel(leaf);
this.runtime.recordLastFocusedPanel(leaf);
}),
);
this.addRibbonIcon("bot-message-square", "Open panel", () => {
void this.panels.activateView();
void this.runtime.activatePanel();
});
this.addCommand({
id: "open-panel",
name: "Open panel",
callback: () => void this.panels.activateView(),
callback: () => void this.runtime.activatePanel(),
});
this.addCommand({
id: "open-new-panel",
name: "Open new panel",
callback: () => void this.panels.activateNewView(),
callback: () => void this.runtime.activateNewPanel(),
});
this.addCommand({
id: "open-threads-view",
name: "Open threads view",
callback: () => void this.activateThreadsView(),
callback: () => void this.runtime.activateThreadsView(),
});
this.addCommand({
id: "open-thread",
name: "Open thread...",
callback: () => void openThreadPicker(this.threadPickerHost()),
callback: () => {
this.runtime.openThreadPicker();
},
});
this.addCommand({
id: "new-chat",
name: "Start new chat",
callback: async () => {
const view = await this.panels.activateView();
await view.startNewThread();
},
callback: () => void this.runtime.startNewChat(),
});
registerSelectionRewriteCommand(this);
this.addSettingTab(new CodexPanelSettingTab(this.app, this, this.settingTabHost()));
this.addSettingTab(new CodexPanelSettingTab(this.app, this, this.runtime.settingTabHost()));
this.panels.scheduleBootRestoredPanelLoads();
this.runtime.scheduleBootRestoredPanelLoads();
}
override onunload(): void {
this.panels.cancelBootRestoredPanelLoads();
}
private async activateThreadsView(): Promise<CodexThreadsView> {
const leaf = await this.app.workspace.ensureSideLeaf(VIEW_TYPE_CODEX_THREADS, "left", {
active: true,
reveal: true,
});
const view = leaf.view as CodexThreadsView;
await view.refresh();
return view;
}
private async openTurnDiff(state: ChatTurnDiffViewState): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0);
const leaf = existing ?? this.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedChatTurnDiffViewState(state) } });
await leaf.loadIfDeferred();
if (leaf.view instanceof CodexChatTurnDiffView) {
leaf.view.setDiffPayload(state);
}
await this.app.workspace.revealLeaf(leaf);
this.runtime.cancelBootRestoredPanelLoads();
}
async loadSettings(): Promise<void> {
@ -126,120 +90,4 @@ export default class CodexPanelPlugin extends Plugin {
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
private sharedAppServerCacheContext(): SharedAppServerCacheContext {
return {
codexPath: this.settings.codexPath,
vaultPath: this.vaultPath,
};
}
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 publishModels(models: Parameters<CodexPanelSettingTabHost["publishModels"]>[0]): void {
this.sharedAppServerCache.applyModelsSnapshot(this.sharedAppServerCacheContext(), models);
this.threadSurfaces.publishModels(models);
}
private chatHost(): CodexChatHost {
return {
settingsRef: this,
workspace: {
openThreadInNewView: (threadId) => this.panels.openThreadInNewView(threadId),
focusThreadInOpenView: (threadId) => this.panels.focusThreadInOpenView(threadId),
openTurnDiff: (state) => this.openTurnDiff(state),
},
threadSurfaces: {
notifyThreadArchived: (threadId) => {
this.threadSurfaces.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
this.threadSurfaces.notifyThreadRenamed(threadId, name);
},
refreshThreadsViewLiveState: () => {
this.threadSurfaces.refreshThreadsViewLiveState();
},
refreshSharedThreadListFromOpenSurface: () => {
this.threadSurfaces.refreshSharedThreadListFromOpenSurface();
},
applyThreadListSnapshot: (threads) => {
this.applyThreadListSnapshot(threads);
},
publishAppServerMetadata: (metadata) => {
this.publishAppServerMetadata(metadata);
},
},
sharedCache: {
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
cachedThreadList: () => this.cachedThreadList(),
cachedAppServerMetadata: () => this.sharedAppServerCache.cachedAppServerMetadata(this.sharedAppServerCacheContext()),
},
};
}
private threadsHost(): CodexThreadsHost {
return {
settings: this.settings,
vaultPath: this.vaultPath,
openNewPanel: () => this.panels.openNewPanel(),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
getOpenPanelSnapshots: () => this.panels.getOpenPanelSnapshots(),
notifyThreadArchived: (threadId, options) => {
this.threadSurfaces.notifyThreadArchived(threadId, options);
},
notifyThreadRenamed: (threadId, name) => {
this.threadSurfaces.notifyThreadRenamed(threadId, name);
},
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
cachedThreadList: () => this.cachedThreadList(),
};
}
private threadPickerHost(): ThreadPickerHost {
return {
app: this.app,
settings: this.settings,
vaultPath: this.vaultPath,
cachedThreadList: () => this.cachedThreadList(),
refreshThreadList: (fetchThreads) => this.refreshThreadList(fetchThreads),
openThreadInCurrentView: (threadId) => this.panels.openThreadInCurrentView(threadId),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
};
}
private settingTabHost(): CodexPanelSettingTabHost {
return {
settings: this.settings,
vaultPath: this.vaultPath,
saveSettings: () => this.saveSettings(),
refreshOpenViews: () => {
this.threadSurfaces.refreshOpenViews();
},
refreshSharedThreadListFromOpenSurface: () => {
this.threadSurfaces.refreshSharedThreadListFromOpenSurface();
},
cachedModels: () => this.sharedAppServerCache.cachedModels(this.sharedAppServerCacheContext()),
publishModels: (models) => {
this.publishModels(models);
},
};
}
}

201
src/plugin-runtime.ts Normal file
View file

@ -0,0 +1,201 @@
import type { App } from "obsidian";
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import { SharedAppServerCache } from "./app-server/services/shared-cache";
import type { SharedAppServerCacheContext } from "./app-server/services/shared-cache-state";
import type { CodexChatHost, PluginSettingsRef } from "./features/chat/application/ports/chat-host";
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import { persistedChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view";
import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal";
import type { CodexThreadsHost, CodexThreadsView } from "./features/threads-view/view";
import type { CodexPanelSettingTabHost } from "./settings/tab";
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
import { createThreadSurfaceActions, type ThreadSurfaceActions } from "./workspace/thread-surface-actions";
import { SharedThreadCatalog } from "./workspace/shared-thread-catalog";
export interface CodexPanelRuntimeOptions {
app: App;
settingsRef: PluginSettingsRef;
saveSettings(): Promise<void>;
}
export class CodexPanelRuntime {
readonly sharedAppServerCache = new SharedAppServerCache();
readonly panels: WorkspacePanelCoordinator;
readonly threadSurfaces: ThreadSurfaceActions;
readonly threadCatalog: SharedThreadCatalog;
constructor(private readonly options: CodexPanelRuntimeOptions) {
this.panels = new WorkspacePanelCoordinator({
app: options.app,
refreshThreadsViewLiveState: () => {
this.threadCatalog.refreshThreadsViewLiveState();
},
});
this.threadSurfaces = createThreadSurfaceActions({
app: options.app,
panels: this.panels,
});
this.threadCatalog = new SharedThreadCatalog({
cache: this.sharedAppServerCache,
surfaces: this.threadSurfaces,
context: () => this.sharedAppServerCacheContext(),
});
}
reset(): void {
this.panels.reset();
}
recordLastFocusedPanel(leaf: Parameters<WorkspacePanelCoordinator["recordLastFocusedPanel"]>[0]): void {
this.panels.recordLastFocusedPanel(leaf);
}
activatePanel(): Promise<unknown> {
return this.panels.activateView();
}
activateNewPanel(): Promise<unknown> {
return this.panels.activateNewView();
}
async startNewChat(): Promise<void> {
const view = await this.panels.activateView();
await view.surface.startNewThread();
}
openThreadPicker(): void {
void openThreadPicker(this.threadPickerHost());
}
async activateThreadsView(): Promise<CodexThreadsView> {
const leaf = await this.options.app.workspace.ensureSideLeaf(VIEW_TYPE_CODEX_THREADS, "left", {
active: true,
reveal: true,
});
const view = leaf.view as CodexThreadsView;
await view.refresh();
return view;
}
scheduleBootRestoredPanelLoads(): void {
this.panels.scheduleBootRestoredPanelLoads();
}
cancelBootRestoredPanelLoads(): void {
this.panels.cancelBootRestoredPanelLoads();
}
chatHost(): CodexChatHost {
return {
settingsRef: this.options.settingsRef,
workspace: {
openThreadInNewView: (threadId) => this.panels.openThreadInNewView(threadId),
focusThreadInOpenView: (threadId) => this.panels.focusThreadInOpenView(threadId),
openTurnDiff: (state) => this.openTurnDiff(state),
},
threadCatalog: {
notifyThreadArchived: (threadId) => {
this.threadCatalog.notifyThreadArchived(threadId);
},
notifyThreadRenamed: (threadId, name) => {
this.threadCatalog.notifyThreadRenamed(threadId, name);
},
refreshThreadsViewLiveState: () => {
this.threadCatalog.refreshThreadsViewLiveState();
},
refreshFromOpenSurface: () => {
this.threadCatalog.refreshFromOpenSurface();
},
applyThreads: (threads) => {
this.threadCatalog.applyThreads(threads);
},
publishAppServerMetadata: (metadata) => {
this.threadCatalog.publishAppServerMetadata(metadata);
},
refreshThreads: (fetchThreads) => this.threadCatalog.refreshThreads(fetchThreads),
cachedThreads: () => this.threadCatalog.cachedThreads(),
cachedAppServerMetadata: () => this.threadCatalog.cachedAppServerMetadata(),
},
};
}
threadsHost(): CodexThreadsHost {
return {
settings: this.options.settingsRef.settings,
vaultPath: this.options.settingsRef.vaultPath,
threadCatalog: {
notifyThreadArchived: (threadId, options) => {
this.threadCatalog.notifyThreadArchived(threadId, options);
},
notifyThreadRenamed: (threadId, name) => {
this.threadCatalog.notifyThreadRenamed(threadId, name);
},
refreshFromOpenSurface: () => {
this.threadCatalog.refreshFromOpenSurface();
},
refreshThreads: (fetchThreads) => this.threadCatalog.refreshThreads(fetchThreads),
cachedThreads: () => this.threadCatalog.cachedThreads(),
},
openNewPanel: () => this.panels.openNewPanel(),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
getOpenPanelSnapshots: () => this.panels.getOpenPanelSnapshots(),
};
}
settingTabHost(): CodexPanelSettingTabHost {
return {
settings: this.options.settingsRef.settings,
vaultPath: this.options.settingsRef.vaultPath,
saveSettings: () => this.options.saveSettings(),
refreshOpenViews: () => {
this.threadSurfaces.refreshOpenViews();
},
threadCatalog: {
refreshFromOpenSurface: () => {
this.threadCatalog.refreshFromOpenSurface();
},
cachedModels: () => {
const models = this.threadCatalog.cachedModels();
return models ? [...models] : null;
},
publishModels: (models) => {
this.threadCatalog.publishModels(models);
},
},
};
}
private threadPickerHost(): ThreadPickerHost {
return {
app: this.options.app,
settings: this.options.settingsRef.settings,
vaultPath: this.options.settingsRef.vaultPath,
threadCatalog: {
cachedThreads: () => this.threadCatalog.cachedThreads(),
refreshThreads: (fetchThreads) => this.threadCatalog.refreshThreads(fetchThreads),
},
openThreadInCurrentView: (threadId) => this.panels.openThreadInCurrentView(threadId),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
};
}
private async openTurnDiff(state: ChatTurnDiffViewState): Promise<void> {
const existing = this.options.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0);
const leaf = existing ?? this.options.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedChatTurnDiffViewState(state) } });
await leaf.loadIfDeferred();
if (leaf.view instanceof CodexChatTurnDiffView) {
leaf.view.setDiffPayload(state);
}
await this.options.app.workspace.revealLeaf(leaf);
}
private sharedAppServerCacheContext(): SharedAppServerCacheContext {
return {
codexPath: this.options.settingsRef.settings.codexPath,
vaultPath: this.options.settingsRef.vaultPath,
};
}
}

View file

@ -20,9 +20,11 @@ import type { CodexPanelSettings } from "./model";
export interface SettingsDynamicDataHost {
settings: CodexPanelSettings;
vaultPath: string;
refreshSharedThreadListFromOpenSurface(): void;
cachedModels(): ModelMetadata[] | null;
publishModels(models: ModelMetadata[]): void;
threadCatalog: {
refreshFromOpenSurface(): void;
cachedModels(): ModelMetadata[] | null;
publishModels(models: ModelMetadata[]): void;
};
}
interface SettingsDynamicDataControllerCallbacks {
@ -59,7 +61,7 @@ export class SettingsDynamicDataController {
private readonly host: SettingsDynamicDataHost,
private readonly callbacks: SettingsDynamicDataControllerCallbacks,
) {
this.models = host.cachedModels() ?? [];
this.models = host.threadCatalog.cachedModels() ?? [];
}
maybeAutoLoadSettingsData(): void {
@ -72,7 +74,7 @@ export class SettingsDynamicDataController {
this.settingsDataAutoLoadStarted = false;
this.settingsDynamicOperationId += 1;
this.settingsDataRefreshLifecycle = { kind: "idle" };
this.models = this.host.cachedModels() ?? [];
this.models = this.host.threadCatalog.cachedModels() ?? [];
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
this.hooks = [];
this.hookWarnings = [];
@ -113,7 +115,7 @@ export class SettingsDynamicDataController {
if (result.models.ok) {
this.models = result.models.data;
this.host.publishModels(result.models.data);
this.host.threadCatalog.publishModels(result.models.data);
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "loaded",
status: result.models.status,
@ -288,7 +290,7 @@ export class SettingsDynamicDataController {
status: `Restored "${archivedThreadDisplayTitle(restoredThread)}".`,
operationId,
});
this.host.refreshSharedThreadListFromOpenSurface();
this.host.threadCatalog.refreshFromOpenSurface();
} catch (error) {
if (this.isStaleSettingsDynamicOperation(operationId)) return;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {

View file

@ -62,8 +62,8 @@ export class WorkspacePanelCoordinator {
reveal: true,
});
const view = leaf.view as CodexChatView;
await view.connect();
view.focusComposer();
await view.surface.connect();
view.surface.focusComposer();
return view;
}
@ -74,14 +74,14 @@ export class WorkspacePanelCoordinator {
await leaf.setViewState({ type: VIEW_TYPE_CODEX_PANEL, active: true });
await this.options.app.workspace.revealLeaf(leaf);
const view = leaf.view as CodexChatView;
if (options.connect !== false) await view.connect();
view.focusComposer();
if (options.connect !== false) await view.surface.connect();
view.surface.focusComposer();
return view;
}
async openThreadInNewView(threadId: string): Promise<CodexChatView> {
const view = await this.activateThreadResumeView();
await view.openThread(threadId);
await view.surface.openThread(threadId);
return view;
}
@ -120,15 +120,15 @@ export class WorkspacePanelCoordinator {
const leaves = this.panelLeaves();
this.ensureInitialFocusedPanel(leaves);
return leaves.flatMap((leaf) =>
leaf.view instanceof CodexChatView ? [this.openPanelSnapshotWithFocus(leaf.view.openPanelSnapshot())] : [],
leaf.view instanceof CodexChatView ? [this.openPanelSnapshotWithFocus(leaf.view.surface.openPanelSnapshot())] : [],
);
}
async focusOpenPanel(viewId: string, threadId: string | null = null): Promise<boolean> {
for (const leaf of this.panelLeaves()) {
if (leaf.view instanceof CodexChatView && leaf.view.openPanelSnapshot().viewId === viewId) {
if (leaf.view instanceof CodexChatView && leaf.view.surface.openPanelSnapshot().viewId === viewId) {
await this.options.app.workspace.revealLeaf(leaf);
await leaf.view.focusThread(threadId);
await leaf.view.surface.focusThread(threadId);
return true;
}
}
@ -145,7 +145,7 @@ export class WorkspacePanelCoordinator {
panelLeavesForThread(threadId: string): WorkspaceLeaf[] {
return this.panelLeaves().filter((leaf) => {
if (leaf.view instanceof CodexChatView) return leaf.view.openPanelSnapshot().threadId === threadId;
if (leaf.view instanceof CodexChatView) return leaf.view.surface.openPanelSnapshot().threadId === threadId;
return restoredThreadId(leaf) === threadId;
});
}
@ -202,7 +202,7 @@ export class WorkspacePanelCoordinator {
private findOpenThreadPanelTarget(threadId: string): ThreadPanelTarget | null {
for (const leaf of this.panelLeaves()) {
if (!(leaf.view instanceof CodexChatView)) continue;
if (leaf.view.openPanelSnapshot().threadId !== threadId) continue;
if (leaf.view.surface.openPanelSnapshot().threadId !== threadId) continue;
return { kind: "open", leaf, view: leaf.view };
}
return null;
@ -220,7 +220,7 @@ export class WorkspacePanelCoordinator {
private findIdleEmptyThreadPanelTarget(): ThreadPanelTarget | null {
for (const leaf of this.panelLeaves()) {
if (!(leaf.view instanceof CodexChatView)) continue;
if (!isIdleEmptyPanelSnapshot(leaf.view.openPanelSnapshot())) continue;
if (!isIdleEmptyPanelSnapshot(leaf.view.surface.openPanelSnapshot())) continue;
return { kind: "empty", leaf, view: leaf.view };
}
return null;
@ -263,15 +263,15 @@ export class WorkspacePanelCoordinator {
await this.options.app.workspace.revealLeaf(target.leaf);
if ("view" in target) {
await target.view.connect();
await target.view.focusThread();
await target.view.surface.connect();
await target.view.surface.focusThread();
return target.view;
}
await target.leaf.loadIfDeferred();
if (target.leaf.view instanceof CodexChatView) {
await target.leaf.view.connect();
await target.leaf.view.focusThread();
await target.leaf.view.surface.connect();
await target.leaf.view.surface.focusThread();
return target.leaf.view;
}
@ -282,26 +282,26 @@ export class WorkspacePanelCoordinator {
switch (target.kind) {
case "open":
await this.options.app.workspace.revealLeaf(target.leaf);
await target.view.focusThread(threadId);
await target.view.surface.focusThread(threadId);
return;
case "restored":
await this.options.app.workspace.revealLeaf(target.leaf);
await target.leaf.loadIfDeferred();
if (target.leaf.view instanceof CodexChatView) {
await target.leaf.view.focusThread(threadId);
await target.leaf.view.surface.focusThread(threadId);
}
return;
case "restored-reuse":
await this.options.app.workspace.revealLeaf(target.leaf);
await target.leaf.loadIfDeferred();
if (target.leaf.view instanceof CodexChatView) {
await target.leaf.view.openThread(threadId);
await target.leaf.view.surface.openThread(threadId);
}
return;
case "empty":
case "reuse":
await this.options.app.workspace.revealLeaf(target.leaf);
await target.view.openThread(threadId);
await target.view.surface.openThread(threadId);
return;
case "new":
await this.openThreadInNewView(threadId);
@ -366,7 +366,7 @@ function isIdleEmptyPanelSnapshot(snapshot: OpenCodexPanelSnapshot): boolean {
}
function focusedPanelViewId(leaf: WorkspaceLeaf | null): string | null {
return leaf?.view instanceof CodexChatView ? leaf.view.openPanelSnapshot().viewId : null;
return leaf?.view instanceof CodexChatView ? leaf.view.surface.openPanelSnapshot().viewId : null;
}
function restoredThreadId(leaf: WorkspaceLeaf): string | null {

View file

@ -0,0 +1,69 @@
import type { ModelMetadata } from "../domain/catalog/metadata";
import type { SharedServerMetadata } from "../domain/server/metadata";
import type { Thread } from "../domain/threads/model";
import type { SharedAppServerCache } from "../app-server/services/shared-cache";
import type { SharedAppServerCacheContext } from "../app-server/services/shared-cache-state";
import type { ThreadSurfaceActions } from "./thread-surface-actions";
export interface SharedThreadCatalogOptions {
cache: SharedAppServerCache;
surfaces: ThreadSurfaceActions;
context: () => SharedAppServerCacheContext;
}
export class SharedThreadCatalog {
constructor(private readonly options: SharedThreadCatalogOptions) {}
cachedThreads(): readonly Thread[] | null {
return this.options.cache.cachedThreadList(this.context());
}
async refreshThreads(fetchThreads: () => Promise<readonly Thread[]>): Promise<readonly Thread[]> {
return this.options.cache.refreshThreadList(this.context(), fetchThreads, (threads) => {
this.options.surfaces.applyThreadListSnapshot(threads);
});
}
applyThreads(threads: readonly Thread[]): void {
this.options.cache.applyThreadListSnapshot(this.context(), threads);
this.options.surfaces.applyThreadListSnapshot(threads);
}
cachedAppServerMetadata(): SharedServerMetadata | null {
return this.options.cache.cachedAppServerMetadata(this.context());
}
publishAppServerMetadata(metadata: SharedServerMetadata): void {
this.options.cache.applyAppServerMetadataSnapshot(this.context(), metadata);
this.options.surfaces.publishAppServerMetadata(metadata);
}
cachedModels(): readonly ModelMetadata[] | null {
return this.options.cache.cachedModels(this.context());
}
publishModels(models: readonly ModelMetadata[]): void {
this.options.cache.applyModelsSnapshot(this.context(), models);
this.options.surfaces.publishModels(models);
}
refreshFromOpenSurface(): void {
this.options.surfaces.refreshSharedThreadListFromOpenSurface();
}
refreshThreadsViewLiveState(): void {
this.options.surfaces.refreshThreadsViewLiveState();
}
notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void {
this.options.surfaces.notifyThreadArchived(threadId, options);
}
notifyThreadRenamed(threadId: string, name: string | null): void {
this.options.surfaces.notifyThreadRenamed(threadId, name);
}
private context(): SharedAppServerCacheContext {
return this.options.context();
}
}

View file

@ -30,9 +30,9 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
.flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : []));
const refreshSharedThreadListFromOpenSurface = (): void => {
const chatView = options.panels.panelViews().find((view) => view.openPanelSnapshot().connected);
const chatView = options.panels.panelViews().find((view) => view.surface.openPanelSnapshot().connected);
if (chatView) {
void chatView.refreshSharedThreadList();
void chatView.surface.refreshSharedThreadList();
return;
}
@ -43,7 +43,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
return {
refreshOpenViews(): void {
for (const view of options.panels.panelViews()) {
view.refreshSettings();
view.surface.refreshSettings();
}
},
@ -51,7 +51,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
applyThreadListSnapshot(threads: readonly Thread[]): void {
for (const view of options.panels.panelViews()) {
view.applyThreadListSnapshot(threads);
view.surface.applyThreadListSnapshot(threads);
}
for (const view of threadsViews()) {
view.applyThreadListSnapshot(threads);
@ -60,13 +60,13 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
publishAppServerMetadata(metadata: SharedServerMetadata): void {
for (const view of options.panels.panelViews()) {
view.applyAppServerMetadataSnapshot(metadata);
view.surface.applyAppServerMetadataSnapshot(metadata);
}
},
publishModels(models: readonly ModelMetadata[]): void {
for (const view of options.panels.panelViews()) {
view.applyAvailableModelsSnapshot(models);
view.surface.applyAvailableModelsSnapshot(models);
}
},
@ -79,7 +79,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
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);
view.surface.notifyThreadArchived(threadId);
}
for (const leaf of leavesToClose) {
leaf.detach();
@ -89,7 +89,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions)
notifyThreadRenamed(threadId: string, name: string | null): void {
for (const view of options.panels.panelViews()) {
view.notifyThreadRenamed(threadId, name);
view.surface.notifyThreadRenamed(threadId, name);
}
refreshSharedThreadListFromOpenSurface();
},

View file

@ -2,8 +2,12 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { messageStreamItems } from "../../../../src/features/chat/application/state/message-stream";
import { AutoTitleController } from "../../../../src/features/chat/application/threads/auto-title-controller";
import { threadTitleContextFromMessageStreamItems } from "../../../../src/features/chat/application/threads/title-context";
import { ThreadTitleService } from "../../../../src/features/threads/thread-title-service";
import type { Thread } from "../../../../src/domain/threads/model";
import type { ThreadTitleContext } from "../../../../src/domain/threads/title-generation-model";
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
import { deferred } from "../../../support/async";
@ -118,19 +122,40 @@ describe("AutoTitleController", () => {
});
function controllerFixture(
overrides: Partial<ConstructorParameters<typeof AutoTitleController>[0]> = {},
): ConstructorParameters<typeof AutoTitleController>[0] & { controller: AutoTitleController } {
overrides: {
currentClient?: () => AppServerClient;
generateThreadTitle?: (context: ThreadTitleContext) => Promise<string | null>;
} = {},
): ConstructorParameters<typeof AutoTitleController>[0] & {
controller: AutoTitleController;
notifyThreadRenamed: ReturnType<typeof vi.fn>;
} {
const stateStore = createChatStateStore();
stateStore.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread")] });
const currentClient = overrides.currentClient ?? (() => fakeClient());
const notifyThreadRenamed = vi.fn();
const titleService = new ThreadTitleService({
settings: {
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
vaultPath: "/vault",
},
currentClient,
visibleCompletedTurnContext: (turnId) =>
threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(stateStore.getState().messageStream)),
generateThreadTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"),
});
const host = {
stateStore,
vaultPath: "/vault",
settings: () => DEFAULT_SETTINGS,
currentClient: () => fakeClient(),
notifyThreadRenamed: vi.fn(),
...overrides,
operations: {
renameThread: async (threadId: string, value: string, options?: { shouldPublish?: () => boolean }) => {
await currentClient().setThreadName(threadId, value);
if (options?.shouldPublish?.() ?? true) notifyThreadRenamed(threadId, value);
return { name: value };
},
},
titleService,
} satisfies ConstructorParameters<typeof AutoTitleController>[0];
return { ...host, controller: new AutoTitleController(host) };
return { ...host, notifyThreadRenamed, controller: new AutoTitleController(host) };
}
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
@ -153,4 +178,6 @@ function threadFixture(id: string): Thread {
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}

View file

@ -5,7 +5,6 @@ import { ThreadRenameEditorController } from "../../../../src/features/chat/appl
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { Thread } from "../../../../src/domain/threads/model";
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
import { DEFAULT_SETTINGS } from "../../../../src/settings/model";
import { deferred } from "../../../support/async";
describe("ThreadRenameEditorController", () => {
@ -100,7 +99,7 @@ describe("ThreadRenameEditorController", () => {
});
it("keeps an edited draft when auto-name generation finishes later", async () => {
const generatedTitle = deferred<string | null>();
const generatedTitle = deferred<string>();
const { controller } = controllerFixture({
generateThreadTitle: vi.fn(() => generatedTitle.promise),
});
@ -119,8 +118,8 @@ describe("ThreadRenameEditorController", () => {
});
it("does not let an older auto-name request finish a newer generation", async () => {
const firstGeneratedTitle = deferred<string | null>();
const secondGeneratedTitle = deferred<string | null>();
const firstGeneratedTitle = deferred<string>();
const secondGeneratedTitle = deferred<string>();
const generateThreadTitle = vi.fn().mockReturnValueOnce(firstGeneratedTitle.promise).mockReturnValueOnce(secondGeneratedTitle.promise);
const { controller } = controllerFixture({ generateThreadTitle });
@ -145,21 +144,40 @@ describe("ThreadRenameEditorController", () => {
});
function controllerFixture(
overrides: Partial<ConstructorParameters<typeof ThreadRenameEditorController>[0]> = {},
): ConstructorParameters<typeof ThreadRenameEditorController>[0] & { controller: ThreadRenameEditorController } {
overrides: Partial<Pick<ConstructorParameters<typeof ThreadRenameEditorController>[0], "ensureConnected" | "addSystemMessage">> & {
currentClient?: () => AppServerClient;
generateThreadTitle?: () => Promise<string>;
} = {},
): ConstructorParameters<typeof ThreadRenameEditorController>[0] & {
controller: ThreadRenameEditorController;
notifyThreadRenamed: ReturnType<typeof vi.fn>;
} {
const stateStore = createChatStateStore();
stateStore.dispatch({ type: "thread-list/applied", threads: [threadFixture("thread")] });
const currentClient = overrides.currentClient ?? (() => fakeClient());
const notifyThreadRenamed = vi.fn();
const host = {
stateStore,
vaultPath: "/vault",
settings: () => DEFAULT_SETTINGS,
ensureConnected: vi.fn().mockResolvedValue(undefined),
currentClient: () => fakeClient(),
addSystemMessage: vi.fn(),
notifyThreadRenamed: vi.fn(),
...overrides,
ensureConnected: overrides.ensureConnected ?? vi.fn().mockResolvedValue(undefined),
addSystemMessage: overrides.addSystemMessage ?? vi.fn(),
operations: {
renameThread: async (threadId: string, value: string) => {
await currentClient().setThreadName(threadId, value);
stateStore.dispatch({
type: "thread-list/applied",
threads: stateStore
.getState()
.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: value } : thread)),
});
notifyThreadRenamed(threadId, value);
return { name: value };
},
},
titleService: {
generateTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"),
},
} satisfies ConstructorParameters<typeof ThreadRenameEditorController>[0];
return { ...host, controller: new ThreadRenameEditorController(host) };
return { ...host, notifyThreadRenamed, controller: new ThreadRenameEditorController(host) };
}
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {

View file

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import { archiveThreadOnAppServer } from "../../../../src/app-server/services/thread-archive";
import type { ArchiveExportAdapter } from "../../../../src/app-server/services/thread-archive-markdown";
import { createChatState } from "../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
@ -170,8 +171,8 @@ describe("thread management actions", () => {
expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked");
expect(host.notifyThreadArchived).toHaveBeenCalledWith("source");
expect(callOrder(adapter.write)).toBeLessThan(callOrder(client.archiveThread));
expect(callOrder(client.archiveThread)).toBeLessThan(callOrder(host.openThreadInCurrentPanel));
expect(callOrder(host.openThreadInCurrentPanel)).toBeLessThan(callOrder(host.notifyThreadArchived));
expect(callOrder(client.archiveThread)).toBeLessThan(callOrder(host.notifyThreadArchived));
expect(callOrder(host.notifyThreadArchived)).toBeLessThan(callOrder(host.openThreadInCurrentPanel));
});
it("keeps the source panel when fork and archive fails to archive", async () => {
@ -306,8 +307,7 @@ describe("thread management actions", () => {
{ kind: "message", role: "assistant", text: "kept answer", turnId: "kept-turn" },
]);
expect(callOrder(host.setComposerText)).toBeLessThan(callOrder(host.addSystemMessage));
expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(vi.mocked(host.refreshThreads)));
expect(callOrder(vi.mocked(host.refreshThreads))).toBeLessThan(callOrder(host.refreshSharedThreadListFromOpenSurface));
expect(callOrder(host.addSystemMessage)).toBeLessThan(callOrder(host.refreshAfterThreadMutation));
});
it("ignores stale rollback responses after the panel switches threads", async () => {
@ -355,7 +355,7 @@ describe("thread management actions", () => {
expect(host.stateStore.getState().activeThread.id).toBe("other");
expect(host.setComposerText).not.toHaveBeenCalled();
expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled();
expect(host.refreshSharedThreadListFromOpenSurface).not.toHaveBeenCalled();
expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled();
});
});
@ -423,25 +423,46 @@ function hostMock({
const state = createChatState();
setChatStateMessageStreamItems(state, items);
const stateStore = createChatStateStore(state);
const notifyThreadArchived = vi.fn();
const notifyThreadRenamed = vi.fn();
const showNotice = vi.fn();
const ensureConnected = vi.fn().mockResolvedValue(undefined);
return {
stateStore,
vaultPath: "/vault",
settings: () => ({ ...DEFAULT_SETTINGS, ...settings }),
archiveAdapter: () => archiveAdapter,
ensureConnected: vi.fn().mockResolvedValue(undefined),
ensureConnected,
currentClient: () => client as unknown as AppServerClient,
addSystemMessage: vi.fn(),
showNotice: vi.fn(),
setStatus: vi.fn(),
setComposerText: vi.fn(),
openThreadInNewView: vi.fn().mockResolvedValue(undefined),
openThreadInCurrentPanel: vi.fn().mockResolvedValue(undefined),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
operations: {
archiveThread: vi.fn(async (threadId: string, options: { saveMarkdown?: boolean } = {}) => {
await ensureConnected();
const result = await archiveThreadOnAppServer(client as unknown as AppServerClient, threadId, {
settings: { ...DEFAULT_SETTINGS, ...settings },
vaultPath: "/vault",
archiveAdapter: () => archiveAdapter,
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled ?? DEFAULT_SETTINGS.archiveExportEnabled,
});
if (result.exportedPath) showNotice(`Saved archived thread to ${result.exportedPath}.`);
notifyThreadArchived(threadId);
return result;
}),
renameThread: vi.fn(async (threadId: string, value: string) => {
await ensureConnected();
await client.setThreadName(threadId, value);
notifyThreadRenamed(threadId, value);
return { name: value };
}),
},
showNotice,
notifyThreadArchived,
notifyThreadRenamed,
notifyActiveThreadIdentityChanged: vi.fn(),
refreshThreads: vi.fn().mockResolvedValue(undefined),
refreshSharedThreadListFromOpenSurface: vi.fn(),
} satisfies ThreadManagementActionsHost;
refreshAfterThreadMutation: vi.fn().mockResolvedValue(undefined),
};
}
function archiveAdapterMock(overrides: Partial<MockArchiveExportAdapter> = {}): MockArchiveExportAdapter {

View file

@ -122,7 +122,7 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView();
await Promise.all([view.connect(), view.connect()]);
await Promise.all([view.surface.connect(), view.surface.connect()]);
expect(connectionMock.state.connectCalls).toBe(1);
expect(client.readEffectiveConfig).toHaveBeenCalledTimes(1);
@ -140,13 +140,13 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView();
const firstConnect = view.connect();
const firstConnect = view.surface.connect();
await waitForAsyncWork(() => {
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
});
let secondResolved = false;
const secondConnect = view.connect().then(() => {
const secondConnect = view.surface.connect().then(() => {
secondResolved = true;
});
await Promise.resolve();
@ -162,20 +162,20 @@ describe("CodexChatView connection lifecycle", () => {
});
it("refreshes shared thread lists after connecting", async () => {
const refreshThreadList = vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>);
const refreshThreads = vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>);
const threads = [threadFixture("thread-1")];
const client = connectedClient({
listThreads: vi.fn().mockResolvedValue({ data: threads }),
});
connectionMock.state.client = client;
const view = await chatView({
host: chatHost({ refreshThreadList }),
host: chatHost({ refreshThreads }),
});
await view.onOpen();
await view.connect();
await view.surface.connect();
expect(refreshThreadList).toHaveBeenCalledOnce();
expect(refreshThreads).toHaveBeenCalledOnce();
expect(client.listThreads).toHaveBeenCalledWith("/vault", { archived: false, cursor: null, limit: 100 });
requiredButton(view.containerEl, '[aria-label="Show thread list"]').click();
await waitForAsyncWork(() => {
@ -190,7 +190,7 @@ describe("CodexChatView connection lifecycle", () => {
host: chatHost({ publishAppServerMetadata }),
});
await view.connect();
await view.surface.connect();
expect(publishAppServerMetadata).toHaveBeenCalledWith(
expect.objectContaining({
@ -272,7 +272,7 @@ describe("CodexChatView connection lifecycle", () => {
},
]);
});
expect(view.openPanelSnapshot()).toMatchObject({ threadId: "thread-new" });
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-new" });
expect(view.containerEl.textContent).toContain("Ship the feature");
});
@ -289,7 +289,7 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView();
const connecting = view.connect();
const connecting = view.surface.connect();
await Promise.resolve();
await view.onClose();
resolveConfig({});
@ -307,7 +307,7 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView();
const connecting = view.connect();
const connecting = view.surface.connect();
await waitForAsyncWork(() => {
expect(client.readEffectiveConfig).toHaveBeenCalledOnce();
});
@ -318,7 +318,7 @@ describe("CodexChatView connection lifecycle", () => {
await connecting;
expect(client.listThreads).not.toHaveBeenCalled();
expect(view.openPanelSnapshot()).toMatchObject({ connected: false });
expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false });
});
it("restores the active thread from workspace state and hydrates it after a delay", async () => {
@ -355,14 +355,14 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.setState({ threadId: "thread-named" }, {} as never);
view.applyThreadListSnapshot([panelThread({ id: "thread-named", name: "作業メモ" })]);
view.surface.applyThreadListSnapshot([panelThread({ id: "thread-named", name: "作業メモ" })]);
expect(view.getDisplayText()).toBe("Codex: 作業メモ");
view.applyThreadListSnapshot([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]);
view.surface.applyThreadListSnapshot([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]);
expect(view.getDisplayText()).toBe("Codex: 初回依頼");
await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never);
view.applyThreadListSnapshot([]);
view.surface.applyThreadListSnapshot([]);
expect(view.getDisplayText()).toBe("Codex: 019e061e");
});
@ -415,7 +415,7 @@ describe("CodexChatView connection lifecycle", () => {
const cachedThread = threadFixture("thread-cached");
const view = await chatView({
host: chatHost({
cachedThreadList: vi.fn(() => [cachedThread] as never[]),
cachedThreads: vi.fn(() => [cachedThread] as never[]),
cachedAppServerMetadata: vi.fn(
() =>
({
@ -451,7 +451,7 @@ describe("CodexChatView connection lifecycle", () => {
await view.onOpen();
expect(client.resumeThread).not.toHaveBeenCalled();
await view.focusThread("thread-1");
await view.surface.focusThread("thread-1");
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
@ -464,7 +464,7 @@ describe("CodexChatView connection lifecycle", () => {
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await view.onOpen();
view.setComposerText("hello");
view.surface.setComposerText("hello");
await submitComposerByEnter(view);
await waitForAsyncWork(() => {
@ -488,8 +488,8 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.onOpen();
await view.openThread("thread-1");
view.setComposerText("hello");
await view.surface.openThread("thread-1");
view.surface.setComposerText("hello");
await submitComposerByEnter(view);
await waitForAsyncWork(() => {
expect(client.startTurn).toHaveBeenCalled();
@ -497,12 +497,12 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.onNotification?.(turnStartedNotification("thread-1", "turn-1"));
connectionMock.state.onNotification?.(turnCompletedNotification("thread-1", "turn-1"));
expect(view.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
startTurn.resolve({ turn: { id: "turn-1" } });
await flushAsyncTicks();
expect(view.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "idle" } });
});
it("requests a workspace layout save after resuming a thread", async () => {
@ -511,7 +511,7 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView({ requestSaveLayout });
await view.openThread("thread-1");
await view.surface.openThread("thread-1");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(requestSaveLayout).toHaveBeenCalledTimes(1);
@ -523,12 +523,12 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView({ requestSaveLayout });
await view.openThread("thread-1");
await view.startNewThread();
await view.surface.openThread("thread-1");
await view.surface.startNewThread();
expect(client.startThread).not.toHaveBeenCalled();
expect(view.getState()).toEqual({ version: 1 });
expect(view.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false });
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false });
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
});
@ -540,9 +540,9 @@ describe("CodexChatView connection lifecycle", () => {
await view.onOpen();
const focus = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => undefined);
await view.openThread("thread-1");
await view.focusThread("thread-1");
await view.startNewThread();
await view.surface.openThread("thread-1");
await view.surface.focusThread("thread-1");
await view.surface.startNewThread();
expect(focus).toHaveBeenCalledTimes(3);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
@ -557,8 +557,8 @@ describe("CodexChatView connection lifecycle", () => {
await view.onOpen();
await view.onOpen();
await view.connect();
view.setComposerText("/clear hello");
await view.surface.connect();
view.surface.setComposerText("/clear hello");
await submitComposerByEnter(view);
expect(client.startThread).not.toHaveBeenCalled();
@ -576,8 +576,8 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView({ host });
await view.onOpen();
await view.connect();
view.setComposerText("/resume thread-1");
await view.surface.connect();
view.surface.setComposerText("/resume thread-1");
await submitComposerByEnter(view);
expect(focusThreadInOpenView).toHaveBeenCalledWith("thread-1");
@ -598,8 +598,8 @@ describe("CodexChatView connection lifecycle", () => {
});
await view.onOpen();
await view.connect();
view.setComposerText("/resume thread-1");
await view.surface.connect();
view.surface.setComposerText("/resume thread-1");
await submitComposerByEnter(view);
expect(focusThreadInOpenView).toHaveBeenCalledWith("thread-1");
@ -615,14 +615,14 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.onOpen();
await view.connect();
await view.surface.connect();
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(messages).not.toBeNull();
if (!messages) return;
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: ESTIMATED_MESSAGE_BLOCK_HEIGHT, clientHeight: 100 });
messages.scrollTop = 0;
view.setComposerText("/resume thread-1");
view.surface.setComposerText("/resume thread-1");
await submitComposerByEnter(view);
await waitForMessagesFrame(messages);
await waitForMessagesFrame(messages);
@ -644,8 +644,8 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView({ host });
await view.onOpen();
await view.connect();
view.setComposerText("/archive thread-1");
await view.surface.connect();
view.surface.setComposerText("/archive thread-1");
await submitComposerByEnter(view);
expect(client.archiveThread).toHaveBeenCalledWith("thread-1");
@ -665,8 +665,8 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView({ host });
await view.onOpen();
await view.connect();
await view.openThread("source");
await view.surface.connect();
await view.surface.openThread("source");
await waitForAsyncWork(() => {
expect(view.containerEl.textContent).toContain("done");
});
@ -681,7 +681,7 @@ describe("CodexChatView connection lifecycle", () => {
expect(client.archiveThread).toHaveBeenCalledWith("source");
expect(client.resumeThread).toHaveBeenLastCalledWith("forked", "/vault");
expect(view.getState()).toEqual({ version: 1, threadId: "forked", threadTitle: "Restored thread" });
expect(view.openPanelSnapshot()).toMatchObject({ threadId: "forked" });
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "forked" });
expect(notifyThreadArchived).toHaveBeenCalledWith("source");
});
});
@ -692,8 +692,8 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView({ requestSaveLayout });
await view.openThread("thread-1");
view.notifyThreadArchived("thread-1");
await view.surface.openThread("thread-1");
view.surface.notifyThreadArchived("thread-1");
expect(view.getState()).toEqual({ version: 1 });
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
@ -703,7 +703,7 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never);
view.notifyThreadRenamed("thread-1", "After rename");
view.surface.notifyThreadRenamed("thread-1", "After rename");
expect(view.getDisplayText()).toBe("Codex: After rename");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" });
@ -717,7 +717,7 @@ describe("CodexChatView connection lifecycle", () => {
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
view.notifyThreadRenamed("thread-1", "Explicit name");
view.surface.notifyThreadRenamed("thread-1", "Explicit name");
await waitForAsyncWork(() => {
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Explicit name”...");
@ -730,7 +730,7 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.onOpen();
await view.openThread("thread-1");
await view.surface.openThread("thread-1");
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Restored thread”...");
});
@ -746,7 +746,7 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.onOpen();
await view.openThread("thread-1");
await view.surface.openThread("thread-1");
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
});
@ -757,14 +757,14 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.onOpen();
await view.openThread("thread-1");
view.notifyThreadRenamed("thread-1", "Renamed thread");
await view.surface.openThread("thread-1");
view.surface.notifyThreadRenamed("thread-1", "Renamed thread");
await waitForAsyncWork(() => {
expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Renamed thread”...");
});
view.notifyThreadRenamed("thread-1", null);
view.surface.notifyThreadRenamed("thread-1", null);
await waitForAsyncWork(() => {
expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task...");
@ -777,15 +777,15 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.onOpen();
await view.openThread("thread-1");
view.setComposerText("keep this draft");
await view.surface.openThread("thread-1");
view.surface.setComposerText("keep this draft");
const composer = composerElement(view);
await waitForAsyncWork(() => {
expect(composer.value).toBe("keep this draft");
});
composer.setSelectionRange(5, 9);
view.notifyThreadRenamed("thread-1", "Renamed thread");
view.surface.notifyThreadRenamed("thread-1", "Renamed thread");
await waitForAsyncWork(() => {
expect(composerElement(view)).toBe(composer);
@ -810,7 +810,7 @@ describe("CodexChatView connection lifecycle", () => {
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: ESTIMATED_MESSAGE_BLOCK_HEIGHT, clientHeight: 100 });
messages.scrollTop = 0;
await view.openThread("thread-1");
await view.surface.openThread("thread-1");
await waitForMessagesFrame(messages);
await waitForMessagesFrame(messages);
@ -828,13 +828,13 @@ describe("CodexChatView connection lifecycle", () => {
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1_000, clientHeight: 100 });
await view.onOpen();
await view.openThread("thread-1");
await view.surface.openThread("thread-1");
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(messages).not.toBeNull();
if (!messages) return;
messages.scrollTop = 0;
await view.focusThread("thread-1");
await view.surface.focusThread("thread-1");
expect(messages.scrollTop).toBe(0);
restoreMessagesLayout();
@ -850,7 +850,7 @@ describe("CodexChatView connection lifecycle", () => {
const restoreMessagesLayout = mockMessagesLayout({ scrollHeight: 1_000, clientHeight: 100 });
await view.onOpen();
await view.openThread("thread-1");
await view.surface.openThread("thread-1");
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(messages).not.toBeNull();
if (!messages) return;
@ -872,7 +872,7 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView();
const opening = view.openThread("thread-1");
const opening = view.surface.openThread("thread-1");
await waitForAsyncWork(() => {
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
});
@ -900,7 +900,7 @@ describe("CodexChatView connection lifecycle", () => {
const view = await chatView();
await view.onOpen();
await view.openThread("thread-1");
await view.surface.openThread("thread-1");
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
expect(client.threadTurnsList).not.toHaveBeenCalled();
@ -919,11 +919,11 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView();
const firstOpen = view.openThread("thread-1");
const firstOpen = view.surface.openThread("thread-1");
await waitForAsyncWork(() => {
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
});
const secondOpen = view.openThread("thread-2");
const secondOpen = view.surface.openThread("thread-2");
await waitForAsyncWork(() => {
expect(client.resumeThread).toHaveBeenCalledWith("thread-2", "/vault");
});
@ -949,11 +949,11 @@ describe("CodexChatView connection lifecycle", () => {
connectionMock.state.client = client;
const view = await chatView();
const firstOpen = view.openThread("thread-1");
const firstOpen = view.surface.openThread("thread-1");
await waitForAsyncWork(() => {
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
});
const secondOpen = view.openThread("thread-2");
const secondOpen = view.surface.openThread("thread-2");
await waitForAsyncWork(() => {
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-2", null, 20);
});
@ -1282,15 +1282,15 @@ interface ChatHostFixtureOverrides {
openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"];
focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"];
openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"];
notifyThreadArchived?: CodexChatHost["threadSurfaces"]["notifyThreadArchived"];
notifyThreadRenamed?: CodexChatHost["threadSurfaces"]["notifyThreadRenamed"];
refreshSharedThreadListFromOpenSurface?: CodexChatHost["threadSurfaces"]["refreshSharedThreadListFromOpenSurface"];
refreshThreadsViewLiveState?: CodexChatHost["threadSurfaces"]["refreshThreadsViewLiveState"];
applyThreadListSnapshot?: CodexChatHost["threadSurfaces"]["applyThreadListSnapshot"];
publishAppServerMetadata?: CodexChatHost["threadSurfaces"]["publishAppServerMetadata"];
refreshThreadList?: CodexChatHost["sharedCache"]["refreshThreadList"];
cachedThreadList?: CodexChatHost["sharedCache"]["cachedThreadList"];
cachedAppServerMetadata?: CodexChatHost["sharedCache"]["cachedAppServerMetadata"];
notifyThreadArchived?: CodexChatHost["threadCatalog"]["notifyThreadArchived"];
notifyThreadRenamed?: CodexChatHost["threadCatalog"]["notifyThreadRenamed"];
refreshFromOpenSurface?: CodexChatHost["threadCatalog"]["refreshFromOpenSurface"];
refreshThreadsViewLiveState?: CodexChatHost["threadCatalog"]["refreshThreadsViewLiveState"];
applyThreads?: CodexChatHost["threadCatalog"]["applyThreads"];
publishAppServerMetadata?: CodexChatHost["threadCatalog"]["publishAppServerMetadata"];
refreshThreads?: CodexChatHost["threadCatalog"]["refreshThreads"];
cachedThreads?: CodexChatHost["threadCatalog"]["cachedThreads"];
cachedAppServerMetadata?: CodexChatHost["threadCatalog"]["cachedAppServerMetadata"];
}
function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost {
@ -1310,21 +1310,19 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost {
focusThreadInOpenView: overrides.focusThreadInOpenView ?? vi.fn().mockResolvedValue(false),
openTurnDiff: overrides.openTurnDiff ?? vi.fn(),
},
threadSurfaces: {
threadCatalog: {
notifyThreadArchived: overrides.notifyThreadArchived ?? vi.fn(),
notifyThreadRenamed: overrides.notifyThreadRenamed ?? vi.fn(),
refreshSharedThreadListFromOpenSurface: overrides.refreshSharedThreadListFromOpenSurface ?? vi.fn(),
refreshFromOpenSurface: overrides.refreshFromOpenSurface ?? vi.fn(),
refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(),
applyThreadListSnapshot: overrides.applyThreadListSnapshot ?? vi.fn(),
applyThreads: overrides.applyThreads ?? vi.fn(),
publishAppServerMetadata: overrides.publishAppServerMetadata ?? vi.fn(),
},
sharedCache: {
refreshThreadList:
overrides.refreshThreadList ??
refreshThreads:
overrides.refreshThreads ??
(vi.fn(
(fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>,
) as CodexChatHost["sharedCache"]["refreshThreadList"]),
cachedThreadList: overrides.cachedThreadList ?? vi.fn(() => null),
) as CodexChatHost["threadCatalog"]["refreshThreads"]),
cachedThreads: overrides.cachedThreads ?? vi.fn(() => null),
cachedAppServerMetadata: overrides.cachedAppServerMetadata ?? vi.fn(() => null),
},
};

View file

@ -92,7 +92,7 @@ function firstSuggestion(modal: CapturedThreadPickerModal): ThreadSuggestion {
}
function isThreadPickerHost(input: readonly Thread[] | TestThreadPickerHost): input is TestThreadPickerHost {
return "cachedThreadList" in input;
return "threadCatalog" in input;
}
interface TestThreadPickerHost extends ThreadPickerHost {
@ -109,8 +109,10 @@ function threadPickerHost(threads: readonly Thread[]): TestThreadPickerHost {
vaultPath: "/vault",
openedCurrent,
openedAvailable,
cachedThreadList: () => threads,
refreshThreadList: async () => threads,
threadCatalog: {
cachedThreads: () => threads,
refreshThreads: async () => threads,
},
openThreadInCurrentView: async (threadId) => {
openedCurrent.push(threadId);
},

View file

@ -237,18 +237,20 @@ describe("CodexThreadsView", () => {
it("refreshes thread lists through the plugin coordinator", async () => {
const threads = [threadFixture({ id: "thread", preview: "Thread preview" })];
const refreshThreadList = vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>);
const refreshThreads = vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>);
connectionMock.state.client = clientFixture({
listThreads: vi.fn().mockResolvedValue({ data: threads }),
});
const host = threadsHost({
refreshThreadList,
threadCatalog: {
refreshThreads,
},
});
const view = await threadsView(host);
await view.refresh();
expect(refreshThreadList).toHaveBeenCalledOnce();
expect(refreshThreads).toHaveBeenCalledOnce();
expect(view.containerEl.textContent).toContain("Thread preview");
});
@ -263,7 +265,9 @@ describe("CodexThreadsView", () => {
});
const view = await threadsView(
threadsHost({
cachedThreadList: vi.fn(() => [threadFixture({ id: "cached", preview: "Cached thread" })]),
threadCatalog: {
cachedThreads: vi.fn(() => [threadFixture({ id: "cached", preview: "Cached thread" })]),
},
}),
);
@ -278,8 +282,9 @@ describe("CodexThreadsView", () => {
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
archiveThread,
});
const notifyThreadArchived = vi.fn();
const host = threadsHost({
notifyThreadArchived: vi.fn(),
threadCatalog: { notifyThreadArchived },
});
const view = await threadsView(host);
@ -289,7 +294,7 @@ describe("CodexThreadsView", () => {
await waitForAsyncWork(() => {
expect(archiveThread).toHaveBeenCalledWith("thread");
expect(host.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
expect(notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
});
});
@ -299,8 +304,9 @@ describe("CodexThreadsView", () => {
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
setThreadName,
});
const notifyThreadRenamed = vi.fn();
const host = threadsHost({
notifyThreadRenamed: vi.fn(),
threadCatalog: { notifyThreadRenamed },
});
const view = await threadsView(host);
@ -314,7 +320,7 @@ describe("CodexThreadsView", () => {
await waitForAsyncWork(() => {
expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread");
expect(host.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread");
expect(notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread");
});
});
@ -405,6 +411,9 @@ function clientFixture(overrides: Record<string, unknown> = {}): Record<string,
}
function threadsHost(overrides: Record<string, unknown> = {}) {
const threadCatalogOverrides =
"threadCatalog" in overrides && typeof overrides["threadCatalog"] === "object" ? overrides["threadCatalog"] : {};
const hostOverrides = Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== "threadCatalog"));
return {
settings: {
...DEFAULT_SETTINGS,
@ -414,11 +423,15 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
openNewPanel: vi.fn().mockResolvedValue(undefined),
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
getOpenPanelSnapshots: vi.fn(() => []),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshThreadList: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
cachedThreadList: vi.fn(() => null),
...overrides,
threadCatalog: {
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshFromOpenSurface: vi.fn(),
refreshThreads: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
cachedThreads: vi.fn(() => null),
...threadCatalogOverrides,
},
...hostOverrides,
};
}

View file

@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/connection/client";
import type { ArchiveThreadResult } from "../../../src/app-server/services/thread-archive";
import type { ArchiveExportAdapter } from "../../../src/app-server/services/thread-archive-markdown";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import { ThreadOperations, type ThreadOperationsHost } from "../../../src/features/threads/thread-operations";
const archiveMock = vi.hoisted(() => ({
archiveThreadOnAppServer: vi.fn(),
}));
vi.mock("../../../src/app-server/services/thread-archive", () => ({
archiveThreadOnAppServer: archiveMock.archiveThreadOnAppServer,
}));
describe("ThreadOperations", () => {
it("renames a thread and notifies shared surfaces after success", async () => {
const { operations, client, catalog } = operationsFixture();
await expect(operations.renameThread("thread", " Saved title ")).resolves.toEqual({ name: "Saved title" });
expect(client?.setThreadName).toHaveBeenCalledWith("thread", "Saved title");
expect(catalog.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Saved title");
});
it("can skip rename publication when the caller invalidates the save", async () => {
const { operations, catalog } = operationsFixture();
await operations.renameThread("thread", "Generated title", { shouldPublish: () => false });
expect(catalog.notifyThreadRenamed).not.toHaveBeenCalled();
});
it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => {
const { operations, catalog, notice } = operationsFixture();
archiveMock.archiveThreadOnAppServer.mockResolvedValueOnce({ exportedPath: "Archive/thread.md" } satisfies ArchiveThreadResult);
await expect(operations.archiveThread("thread", { saveMarkdown: true, closeOpenPanels: true })).resolves.toEqual({
exportedPath: "Archive/thread.md",
});
expect(notice).toHaveBeenCalledWith("Saved archived thread to Archive/thread.md.");
expect(catalog.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
});
it("does not notify surfaces when an operation has no current client", async () => {
const { operations, catalog } = operationsFixture({ client: null });
await expect(operations.renameThread("thread", "Title")).resolves.toBeNull();
await expect(operations.archiveThread("thread")).resolves.toBeNull();
expect(catalog.notifyThreadRenamed).not.toHaveBeenCalled();
expect(catalog.notifyThreadArchived).not.toHaveBeenCalled();
});
});
function operationsFixture(options: { client?: MockClient | null } = {}) {
archiveMock.archiveThreadOnAppServer.mockReset();
archiveMock.archiveThreadOnAppServer.mockResolvedValue({ exportedPath: null } satisfies ArchiveThreadResult);
const client = options.client === undefined ? clientMock() : options.client;
const catalog = {
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshFromOpenSurface: vi.fn(),
};
const notice = vi.fn();
const host: ThreadOperationsHost = {
connection: {
ensureConnected: vi.fn().mockResolvedValue(undefined),
currentClient: () => client as AppServerClient | null,
},
settings: {
current: () => ({ ...DEFAULT_SETTINGS, archiveExportEnabled: false }),
vaultPath: "/vault",
},
archiveAdapter: () => archiveAdapterMock(),
catalog,
notice,
};
return { operations: new ThreadOperations(host), client, catalog, notice };
}
type MockClient = ReturnType<typeof clientMock>;
function clientMock() {
return {
setThreadName: vi.fn().mockResolvedValue({}),
archiveThread: vi.fn().mockResolvedValue({}),
};
}
function archiveAdapterMock(): ArchiveExportAdapter {
return {
exists: vi.fn().mockResolvedValue(false),
mkdir: vi.fn().mockResolvedValue(undefined),
write: vi.fn().mockResolvedValue(undefined),
};
}

View file

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/connection/client";
import { THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, type ThreadTitleContext } from "../../../src/domain/threads/title-generation-model";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import { ThreadTitleService } from "../../../src/features/threads/thread-title-service";
describe("ThreadTitleService", () => {
it("generates a title from visible context without saving it", async () => {
const generateThreadTitle = vi.fn().mockResolvedValue("Generated title");
const service = titleService({
visibleContext: () => titleContext("visible request", "visible response"),
generateThreadTitle,
});
await expect(service.generateTitle("thread")).resolves.toBe("Generated title");
expect(generateThreadTitle).toHaveBeenCalledWith(titleContext("visible request", "visible response"));
});
it("throws the existing unavailable-context error when no context can be resolved", async () => {
const service = titleService();
await expect(service.generateTitle("thread")).rejects.toThrow(THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE);
});
it("prefers visible completed-turn context over completed summaries", () => {
const service = titleService({
visibleCompletedTurnContext: () => titleContext("visible turn", "visible answer"),
});
expect(service.completedTurnContext("turn", { userText: "summary turn", assistantText: "summary answer" })).toEqual(
titleContext("visible turn", "visible answer"),
);
});
it("falls back from visible completed-turn context to completed summaries", () => {
const service = titleService({
visibleCompletedTurnContext: () => null,
});
expect(service.completedTurnContext("turn", { userText: "summary turn", assistantText: "summary answer" })).toEqual(
titleContext("summary turn", "summary answer"),
);
});
});
function titleService(options: Partial<ConstructorParameters<typeof ThreadTitleService>[0]> = {}): ThreadTitleService {
return new ThreadTitleService({
settings: {
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
vaultPath: "/vault",
},
currentClient: () => null as AppServerClient | null,
...options,
});
}
function titleContext(userRequest: string, assistantResponse: string): ThreadTitleContext {
return { userRequest, assistantResponse };
}

View file

@ -18,15 +18,15 @@ import { installObsidianDomShims } from "./support/dom";
installObsidianDomShims();
function panels(plugin: CodexPanelPlugin): WorkspacePanelCoordinator {
return (plugin as unknown as { panels: WorkspacePanelCoordinator }).panels;
return plugin.runtime.panels;
}
function threadSurfaces(plugin: CodexPanelPlugin): ThreadSurfaceActions {
return (plugin as unknown as { threadSurfaces: ThreadSurfaceActions }).threadSurfaces;
return plugin.runtime.threadSurfaces;
}
function sharedAppServerCache(plugin: CodexPanelPlugin): SharedAppServerCache {
return (plugin as unknown as { sharedAppServerCache: SharedAppServerCache }).sharedAppServerCache;
return plugin.runtime.sharedAppServerCache;
}
function sharedAppServerCacheContext(
@ -96,7 +96,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const openLeaf = leaf();
openLeaf.view = chatView(CodexChatView, openLeaf);
const openView = openLeaf.view as CodexChatView;
vi.spyOn(openView, "openPanelSnapshot").mockReturnValue({
vi.spyOn(openView.surface, "openPanelSnapshot").mockReturnValue({
viewId: "open-view",
threadId: "thread-1",
lastFocused: false,
@ -106,11 +106,11 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
hasComposerDraft: false,
connected: true,
});
vi.spyOn(openView, "focusThread").mockResolvedValue(undefined);
vi.spyOn(openView.surface, "focusThread").mockResolvedValue(undefined);
const emptyLeaf = leaf();
emptyLeaf.view = chatView(CodexChatView, emptyLeaf);
const emptyView = emptyLeaf.view as CodexChatView;
const openEmptyThread = vi.spyOn(emptyView, "openThread").mockResolvedValue(undefined);
const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([openLeaf, emptyLeaf]);
await panels(plugin).openThreadInAvailableView("thread-1");
@ -124,7 +124,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const busyLeaf = leaf();
busyLeaf.view = chatView(CodexChatView, busyLeaf);
const busyView = busyLeaf.view as CodexChatView;
vi.spyOn(busyView, "openPanelSnapshot").mockReturnValue({
vi.spyOn(busyView.surface, "openPanelSnapshot").mockReturnValue({
viewId: "busy-view",
threadId: "other-thread",
lastFocused: false,
@ -137,7 +137,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const emptyLeaf = leaf();
emptyLeaf.view = chatView(CodexChatView, emptyLeaf);
const emptyView = emptyLeaf.view as CodexChatView;
vi.spyOn(emptyView, "openPanelSnapshot").mockReturnValue({
vi.spyOn(emptyView.surface, "openPanelSnapshot").mockReturnValue({
viewId: "empty-view",
threadId: null,
lastFocused: false,
@ -147,7 +147,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
hasComposerDraft: false,
connected: true,
});
const openEmptyThread = vi.spyOn(emptyView, "openThread").mockResolvedValue(undefined);
const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([busyLeaf, emptyLeaf]);
await panels(plugin).openThreadInAvailableView("thread-1");
@ -160,11 +160,11 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const olderLeaf = leaf();
olderLeaf.view = chatView(CodexChatView, olderLeaf);
const olderView = olderLeaf.view as CodexChatView;
const openOlderThread = vi.spyOn(olderView, "openThread").mockResolvedValue(undefined);
const openOlderThread = vi.spyOn(olderView.surface, "openThread").mockResolvedValue(undefined);
const currentLeaf = leaf();
currentLeaf.view = chatView(CodexChatView, currentLeaf);
const currentView = currentLeaf.view as CodexChatView;
const openCurrentThread = vi.spyOn(currentView, "openThread").mockResolvedValue(undefined);
const openCurrentThread = vi.spyOn(currentView.surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([olderLeaf, currentLeaf]);
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(currentLeaf);
@ -179,11 +179,11 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const fallbackLeaf = leaf();
fallbackLeaf.view = chatView(CodexChatView, fallbackLeaf);
const fallbackView = fallbackLeaf.view as CodexChatView;
const openFallbackThread = vi.spyOn(fallbackView, "openThread").mockResolvedValue(undefined);
const openFallbackThread = vi.spyOn(fallbackView.surface, "openThread").mockResolvedValue(undefined);
const activeLeaf = leaf();
activeLeaf.view = chatView(CodexChatView, activeLeaf);
const activeView = activeLeaf.view as CodexChatView;
const openActiveThread = vi.spyOn(activeView, "openThread").mockResolvedValue(undefined);
const openActiveThread = vi.spyOn(activeView.surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([fallbackLeaf, activeLeaf]);
(plugin.app.workspace.getActiveViewOfType as ReturnType<typeof vi.fn>).mockReturnValue(activeView);
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(fallbackLeaf);
@ -199,12 +199,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const openLeaf = leaf();
openLeaf.view = chatView(CodexChatView, openLeaf);
const openView = openLeaf.view as CodexChatView;
vi.spyOn(openView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "open-view", threadId: "thread-1" }));
const focusOpenThread = vi.spyOn(openView, "focusThread").mockResolvedValue(undefined);
vi.spyOn(openView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "open-view", threadId: "thread-1" }));
const focusOpenThread = vi.spyOn(openView.surface, "focusThread").mockResolvedValue(undefined);
const currentLeaf = leaf();
currentLeaf.view = chatView(CodexChatView, currentLeaf);
const currentView = currentLeaf.view as CodexChatView;
const openCurrentThread = vi.spyOn(currentView, "openThread").mockResolvedValue(undefined);
const openCurrentThread = vi.spyOn(currentView.surface, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([openLeaf, currentLeaf]);
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(currentLeaf);
@ -220,8 +220,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
(plugin.app.workspace.getMostRecentLeaf as ReturnType<typeof vi.fn>).mockReturnValue(restoredLeaf);
const { CodexChatView } = await import("../src/features/chat/host/view");
const view = chatView(CodexChatView, restoredLeaf);
const openThread = vi.spyOn(view, "openThread").mockResolvedValue(undefined);
const focusThread = vi.spyOn(view, "focusThread").mockResolvedValue(undefined);
const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined);
const focusThread = vi.spyOn(view.surface, "focusThread").mockResolvedValue(undefined);
restoredLeaf.loadIfDeferred.mockImplementation(async () => {
restoredLeaf.view = view;
});
@ -242,9 +242,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
newLeaf.setViewState.mockImplementation(async () => {
newLeaf.view = view;
});
const connect = vi.spyOn(view, "connect").mockResolvedValue(undefined);
const focusComposer = vi.spyOn(view, "focusComposer").mockImplementation(() => undefined);
const openThread = vi.spyOn(view, "openThread").mockResolvedValue(undefined);
const connect = vi.spyOn(view.surface, "connect").mockResolvedValue(undefined);
const focusComposer = vi.spyOn(view.surface, "focusComposer").mockImplementation(() => undefined);
const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined);
await panels(plugin).openThreadInNewView("thread-1");
@ -258,13 +258,13 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const firstLeaf = leaf();
firstLeaf.view = chatView(CodexChatView, firstLeaf);
const firstView = firstLeaf.view as CodexChatView;
const connectFirst = vi.spyOn(firstView, "connect").mockResolvedValue(undefined);
const focusFirst = vi.spyOn(firstView, "focusThread").mockResolvedValue(undefined);
const connectFirst = vi.spyOn(firstView.surface, "connect").mockResolvedValue(undefined);
const focusFirst = vi.spyOn(firstView.surface, "focusThread").mockResolvedValue(undefined);
const activeLeaf = leaf();
activeLeaf.view = chatView(CodexChatView, activeLeaf);
const activeView = activeLeaf.view as CodexChatView;
const connectActive = vi.spyOn(activeView, "connect").mockResolvedValue(undefined);
const focusActive = vi.spyOn(activeView, "focusThread").mockResolvedValue(undefined);
const connectActive = vi.spyOn(activeView.surface, "connect").mockResolvedValue(undefined);
const focusActive = vi.spyOn(activeView.surface, "focusThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([firstLeaf, activeLeaf]);
(plugin.app.workspace.getActiveViewOfType as ReturnType<typeof vi.fn>).mockReturnValue(activeView);
@ -282,12 +282,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const { CodexChatView } = await import("../src/features/chat/host/view");
const firstLeaf = leaf();
firstLeaf.view = chatView(CodexChatView, firstLeaf);
vi.spyOn(firstLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "first", threadId: "thread-1" }),
);
const secondLeaf = leaf();
secondLeaf.view = chatView(CodexChatView, secondLeaf);
vi.spyOn(secondLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
vi.spyOn((secondLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "second", threadId: "thread-2" }),
);
const plugin = await pluginWithLeaves([firstLeaf, secondLeaf]);
@ -314,13 +314,13 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const { CodexChatView } = await import("../src/features/chat/host/view");
const firstLeaf = leaf();
firstLeaf.view = chatView(CodexChatView, firstLeaf);
vi.spyOn(firstLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "first", threadId: "thread-1" }),
);
const activeLeaf = leaf();
activeLeaf.view = chatView(CodexChatView, activeLeaf);
const activeView = activeLeaf.view as CodexChatView;
vi.spyOn(activeView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "active", threadId: "thread-2" }));
vi.spyOn(activeView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "active", threadId: "thread-2" }));
const plugin = await pluginWithLeaves([firstLeaf, activeLeaf]);
(plugin.app.workspace.getActiveViewOfType as ReturnType<typeof vi.fn>).mockReturnValue(activeView);
@ -334,12 +334,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const { CodexChatView } = await import("../src/features/chat/host/view");
const recentLeaf = leaf();
recentLeaf.view = chatView(CodexChatView, recentLeaf);
vi.spyOn(recentLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
vi.spyOn((recentLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "recent", threadId: "thread-1" }),
);
const otherLeaf = leaf();
otherLeaf.view = chatView(CodexChatView, otherLeaf);
vi.spyOn(otherLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(
vi.spyOn((otherLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "other", threadId: "thread-2" }),
);
const plugin = await pluginWithLeaves([recentLeaf, otherLeaf]);
@ -360,9 +360,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
newLeaf.setViewState.mockImplementation(async () => {
newLeaf.view = view;
});
const connect = vi.spyOn(view, "connect").mockResolvedValue(undefined);
const focusComposer = vi.spyOn(view, "focusComposer").mockImplementation(() => undefined);
const openThread = vi.spyOn(view, "openThread").mockResolvedValue(undefined);
const connect = vi.spyOn(view.surface, "connect").mockResolvedValue(undefined);
const focusComposer = vi.spyOn(view.surface, "focusComposer").mockImplementation(() => undefined);
const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined);
await panels(plugin).openNewPanel();
@ -376,8 +376,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const connectedLeaf = leaf();
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const refreshSharedThreadList = vi.spyOn(connectedView, "refreshSharedThreadList").mockResolvedValue(undefined);
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([connectedLeaf]);
threadSurfaces(plugin).notifyThreadArchived("thread-1");
@ -390,12 +390,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const restoredMatchingLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored" } });
const matchingLeaf = leaf();
matchingLeaf.view = chatView(CodexChatView, matchingLeaf);
vi.spyOn(matchingLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-1" }));
vi.spyOn(matchingLeaf.view as CodexChatView, "refreshSharedThreadList").mockResolvedValue(undefined);
vi.spyOn((matchingLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-1" }));
vi.spyOn((matchingLeaf.view as CodexChatView).surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const otherLeaf = leaf();
otherLeaf.view = chatView(CodexChatView, otherLeaf);
vi.spyOn(otherLeaf.view as CodexChatView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-2" }));
vi.spyOn(otherLeaf.view as CodexChatView, "refreshSharedThreadList").mockResolvedValue(undefined);
vi.spyOn((otherLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-2" }));
vi.spyOn((otherLeaf.view as CodexChatView).surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([restoredMatchingLeaf, matchingLeaf, otherLeaf]);
threadSurfaces(plugin).notifyThreadArchived("thread-1");
@ -416,8 +416,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const connectedLeaf = leaf();
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const refreshSharedThreadList = vi.spyOn(connectedView, "refreshSharedThreadList").mockResolvedValue(undefined);
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([connectedLeaf]);
threadSurfaces(plugin).notifyThreadRenamed("thread-1", "Renamed thread");
@ -491,14 +491,14 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const disconnectedLeaf = leaf();
disconnectedLeaf.view = chatView(CodexChatView, disconnectedLeaf);
const disconnectedView = disconnectedLeaf.view as CodexChatView;
vi.spyOn(disconnectedView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "disconnected", connected: false }));
const disconnectedRefresh = vi.spyOn(disconnectedView, "refreshSharedThreadList").mockResolvedValue(undefined);
vi.spyOn(disconnectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "disconnected", connected: false }));
const disconnectedRefresh = vi.spyOn(disconnectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const connectedLeaf = leaf();
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const connectedRefresh = vi.spyOn(connectedView, "refreshSharedThreadList").mockResolvedValue(undefined);
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const connectedRefresh = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([disconnectedLeaf, connectedLeaf]);
@ -513,16 +513,18 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
const sourceLeaf = leaf();
sourceLeaf.view = chatView(CodexChatView, sourceLeaf);
const sourceView = sourceLeaf.view as CodexChatView;
vi.spyOn(sourceView, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "source", threadId: "thread-1", connected: true }));
const sourceRefresh = vi.spyOn(sourceView, "refreshSharedThreadList").mockResolvedValue(undefined);
vi.spyOn(sourceView.surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "source", threadId: "thread-1", connected: true }),
);
const sourceRefresh = vi.spyOn(sourceView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const remainingLeaf = leaf();
remainingLeaf.view = chatView(CodexChatView, remainingLeaf);
const remainingView = remainingLeaf.view as CodexChatView;
vi.spyOn(remainingView, "openPanelSnapshot").mockReturnValue(
vi.spyOn(remainingView.surface, "openPanelSnapshot").mockReturnValue(
panelSnapshot({ viewId: "remaining", threadId: "forked-thread", connected: true }),
);
const remainingRefresh = vi.spyOn(remainingView, "refreshSharedThreadList").mockResolvedValue(undefined);
const remainingRefresh = vi.spyOn(remainingView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const leaves = [sourceLeaf, remainingLeaf];
sourceLeaf.detach.mockImplementation(() => {
@ -617,17 +619,15 @@ function chatHostFixture(): CodexChatHost {
focusThreadInOpenView: vi.fn(),
openTurnDiff: vi.fn(),
},
threadSurfaces: {
threadCatalog: {
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshSharedThreadListFromOpenSurface: vi.fn(),
refreshFromOpenSurface: vi.fn(),
refreshThreadsViewLiveState: vi.fn(),
applyThreadListSnapshot: vi.fn(),
applyThreads: vi.fn(),
publishAppServerMetadata: vi.fn(),
},
sharedCache: {
refreshThreadList: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
cachedThreadList: vi.fn(() => null),
refreshThreads: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
cachedThreads: vi.fn(() => null),
cachedAppServerMetadata: vi.fn(() => null),
},
};
@ -645,8 +645,8 @@ function thread(id: string): Thread {
}
function panelSnapshot(
overrides: Partial<ReturnType<CodexChatView["openPanelSnapshot"]>> = {},
): ReturnType<CodexChatView["openPanelSnapshot"]> {
overrides: Partial<ReturnType<CodexChatView["surface"]["openPanelSnapshot"]>> = {},
): ReturnType<CodexChatView["surface"]["openPanelSnapshot"]> {
return {
viewId: "view",
threadId: "thread",

View file

@ -318,7 +318,7 @@ describe("settings tab", () => {
it("ignores stale archived restore results after a newer dynamic operation completes", async () => {
const staleRestore = deferred<{ thread: ThreadRecord }>();
const refreshSharedThreadListFromOpenSurface = vi.fn();
const refreshFromOpenSurface = vi.fn();
const initialClient = settingsClient({
threads: [appServerThread({ id: "thread-old", preview: "Old archived" })],
});
@ -338,7 +338,7 @@ describe("settings tab", () => {
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(newerClient),
);
const controller = new SettingsDynamicDataController(settingsTabHost({ refreshSharedThreadListFromOpenSurface }), {
const controller = new SettingsDynamicDataController(settingsTabHost({ refreshFromOpenSurface }), {
display: vi.fn(),
notify: vi.fn(),
});
@ -353,7 +353,7 @@ describe("settings tab", () => {
staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
await restore;
expect(refreshSharedThreadListFromOpenSurface).not.toHaveBeenCalled();
expect(refreshFromOpenSurface).not.toHaveBeenCalled();
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
});
@ -551,7 +551,7 @@ function newSettingsTab(
cachedModels?: ModelMetadata[];
publishModels?: (models: ModelMetadata[]) => void;
refreshOpenViews?: () => void;
refreshSharedThreadListFromOpenSurface?: () => void;
refreshFromOpenSurface?: () => void;
settings?: Partial<{
threadNamingModel: string | null;
threadNamingEffort: string | null;
@ -570,7 +570,7 @@ function settingsTabHost(
cachedModels?: ModelMetadata[];
publishModels?: (models: ModelMetadata[]) => void;
refreshOpenViews?: () => void;
refreshSharedThreadListFromOpenSurface?: () => void;
refreshFromOpenSurface?: () => void;
settings?: Partial<{
threadNamingModel: string | null;
threadNamingEffort: string | null;
@ -597,9 +597,11 @@ function settingsTabHost(
vaultPath: "/vault",
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
refreshOpenViews: options.refreshOpenViews ?? vi.fn(),
refreshSharedThreadListFromOpenSurface: options.refreshSharedThreadListFromOpenSurface ?? vi.fn(),
cachedModels: vi.fn(() => options.cachedModels ?? []),
publishModels: options.publishModels ?? vi.fn(),
threadCatalog: {
refreshFromOpenSurface: options.refreshFromOpenSurface ?? vi.fn(),
cachedModels: vi.fn(() => options.cachedModels ?? []),
publishModels: options.publishModels ?? vi.fn(),
},
};
}

View file

@ -0,0 +1,129 @@
import { describe, expect, it, vi, type Mock } from "vitest";
import { SharedAppServerCache } from "../../src/app-server/services/shared-cache";
import type { ModelMetadata } from "../../src/domain/catalog/metadata";
import { createServerDiagnostics } from "../../src/domain/server/diagnostics";
import type { SharedServerMetadata } from "../../src/domain/server/metadata";
import type { Thread } from "../../src/domain/threads/model";
import { SharedThreadCatalog } from "../../src/workspace/shared-thread-catalog";
import type { ThreadSurfaceActions } from "../../src/workspace/thread-surface-actions";
type MockSurfaceActions = ThreadSurfaceActions & {
applyThreadListSnapshot: Mock<(threads: readonly Thread[]) => void>;
publishAppServerMetadata: Mock<(metadata: SharedServerMetadata) => void>;
publishModels: Mock<(models: readonly ModelMetadata[]) => void>;
notifyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>;
notifyThreadRenamed: Mock<(threadId: string, name: string | null) => void>;
};
describe("SharedThreadCatalog", () => {
it("applies thread snapshots to the shared cache and open surfaces", () => {
const { catalog, surfaces } = catalogFixture();
const threads = [thread("thread")];
catalog.applyThreads(threads);
expect(catalog.cachedThreads()).toEqual(threads);
expect(surfaces.applyThreadListSnapshot).toHaveBeenCalledWith(threads);
});
it("refreshes thread snapshots through the cache single-flight and publishes the snapshot once", async () => {
const { catalog, surfaces } = catalogFixture();
const fetchThreads = vi.fn().mockResolvedValue([thread("thread")]);
const first = catalog.refreshThreads(fetchThreads);
const second = catalog.refreshThreads(fetchThreads);
await expect(first).resolves.toEqual([thread("thread")]);
await expect(second).resolves.toEqual([thread("thread")]);
expect(fetchThreads).toHaveBeenCalledOnce();
expect(catalog.cachedThreads()).toEqual([thread("thread")]);
expect(surfaces.applyThreadListSnapshot).toHaveBeenCalledOnce();
});
it("publishes metadata and model snapshots to cache and surfaces", () => {
const { catalog, surfaces } = catalogFixture();
const metadata = serverMetadata({ availableModels: [model("gpt-test")] });
const models = [model("gpt-other")];
catalog.publishAppServerMetadata(metadata);
catalog.publishModels(models);
expect(catalog.cachedAppServerMetadata()).toEqual({ ...metadata, availableModels: models });
expect(catalog.cachedModels()).toEqual(models);
expect(surfaces.publishAppServerMetadata).toHaveBeenCalledWith(metadata);
expect(surfaces.publishModels).toHaveBeenCalledWith(models);
});
it("forwards archive and rename notifications through the surface owner", () => {
const { catalog, surfaces } = catalogFixture();
catalog.notifyThreadArchived("thread", { closeOpenPanels: true });
catalog.notifyThreadRenamed("thread", "Renamed");
expect(surfaces.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true });
expect(surfaces.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed");
});
});
function catalogFixture() {
const surfaces = surfaceActions();
const catalog = new SharedThreadCatalog({
cache: new SharedAppServerCache(),
surfaces,
context: () => ({ codexPath: "codex", vaultPath: "/vault" }),
});
return { catalog, surfaces };
}
function surfaceActions(): MockSurfaceActions {
return {
refreshOpenViews: vi.fn(),
refreshSharedThreadListFromOpenSurface: vi.fn(),
applyThreadListSnapshot: vi.fn(),
publishAppServerMetadata: vi.fn(),
publishModels: vi.fn(),
refreshThreadsViewLiveState: vi.fn(),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
};
}
function thread(id: string): Thread {
return {
id,
preview: id,
createdAt: 1,
updatedAt: 1,
name: null,
archived: false,
};
}
function model(modelId: string): ModelMetadata {
return {
id: modelId,
model: modelId,
displayName: modelId,
description: "",
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: [],
additionalSpeedTiers: [],
serviceTiers: [],
defaultServiceTier: null,
isDefault: false,
};
}
function serverMetadata(overrides: Partial<SharedServerMetadata> = {}): SharedServerMetadata {
return {
runtimeConfig: null,
availableModels: [],
availableSkills: [],
rateLimit: null,
serverDiagnostics: createServerDiagnostics(),
...overrides,
};
}

View file

@ -20,8 +20,10 @@ describe("createThreadSurfaceActions", () => {
panels: {
panelViews: () => [
{
openPanelSnapshot: () => panelSnapshot({ connected: false }),
refreshSharedThreadList: disconnectedPanelRefresh,
surface: {
openPanelSnapshot: () => panelSnapshot({ connected: false }),
refreshSharedThreadList: disconnectedPanelRefresh,
},
},
],
} as never,
@ -48,12 +50,16 @@ describe("createThreadSurfaceActions", () => {
panels: {
panelViews: () => [
{
openPanelSnapshot: () => panelSnapshot({ viewId: "disconnected", connected: false }),
refreshSharedThreadList: disconnectedPanelRefresh,
surface: {
openPanelSnapshot: () => panelSnapshot({ viewId: "disconnected", connected: false }),
refreshSharedThreadList: disconnectedPanelRefresh,
},
},
{
openPanelSnapshot: () => panelSnapshot({ viewId: "connected", connected: true }),
refreshSharedThreadList: connectedPanelRefresh,
surface: {
openPanelSnapshot: () => panelSnapshot({ viewId: "connected", connected: true }),
refreshSharedThreadList: connectedPanelRefresh,
},
},
],
} as never,