diff --git a/src/features/chat/application/ports/chat-host.ts b/src/features/chat/application/ports/chat-host.ts index 16e51630..9d8484ad 100644 --- a/src/features/chat/application/ports/chat-host.ts +++ b/src/features/chat/application/ports/chat-host.ts @@ -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; } -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): Promise; - cachedThreadList(): readonly Thread[] | null; + refreshThreads(fetchThreads: () => Promise): Promise; + cachedThreads(): readonly Thread[] | null; cachedAppServerMetadata(): SharedServerMetadata | null; } diff --git a/src/features/chat/application/threads/auto-title-controller.ts b/src/features/chat/application/threads/auto-title-controller.ts index d664779b..14d8c59a 100644 --- a/src/features/chat/application/threads/auto-title-controller.ts +++ b/src/features/chat/application/threads/auto-title-controller.ts @@ -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; + operations: Pick; + titleService: Pick; } 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 { 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 { - 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 { diff --git a/src/features/chat/application/threads/composition.ts b/src/features/chat/application/threads/composition.ts index 27eb23da..97502717 100644 --- a/src/features/chat/application/threads/composition.ts +++ b/src/features/chat/application/threads/composition.ts @@ -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; - threadSurfaces: ThreadSurfaceBroadcaster; + threadCatalog: Pick; 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); diff --git a/src/features/chat/application/threads/rename-editor-controller.ts b/src/features/chat/application/threads/rename-editor-controller.ts index 7013f9c7..e5a04f5e 100644 --- a/src/features/chat/application/threads/rename-editor-controller.ts +++ b/src/features/chat/application/threads/rename-editor-controller.ts @@ -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; - currentClient: () => AppServerClient | null; addSystemMessage: (text: string) => void; - notifyThreadRenamed: (threadId: string, name: string) => void; - generateThreadTitle?: (context: ThreadTitleContext) => Promise; + operations: Pick; + titleService: Pick; } 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 { - 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 { - 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; +} diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 9e4c85d2..ffc17b22 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -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; ensureConnected: () => Promise; currentClient: () => AppServerClient | null; addSystemMessage: (text: string) => void; - showNotice: (text: string) => void; setStatus: (status: string) => void; setComposerText: (text: string) => void; openThreadInNewView: (threadId: string) => Promise; openThreadInCurrentPanel: (threadId: string) => Promise; - notifyThreadArchived: (threadId: string) => void; - notifyThreadRenamed: (threadId: string, name: string) => void; notifyActiveThreadIdentityChanged: () => void; - refreshThreads: () => Promise; - refreshSharedThreadListFromOpenSurface: () => void; + refreshAfterThreadMutation: () => Promise; } export interface ThreadManagementActions { @@ -55,16 +48,6 @@ export interface ThreadManagementActions { rollbackThread: (threadId: string) => Promise; } -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 { - if (await archiveThreadFromPanel(host, threadId, saveMarkdown)) { - host.notifyThreadArchived(threadId); - } +async function archiveThread(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise { + await archiveThreadFromPanel(host, threadId, saveMarkdown); } -async function archiveThreadFromPanel( - host: ThreadManagementActionsHost, - threadId: string, - saveMarkdown = host.settings().archiveExportEnabled, -): Promise { +async function archiveThreadFromPanel(host: ThreadManagementActionsHost, threadId: string, saveMarkdown?: boolean): Promise { 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 { +async function renameThread(host: ThreadManagementActionsHost, threadId: string, value: string): Promise { 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 { - 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); diff --git a/src/features/chat/host/connection-bundle.ts b/src/features/chat/host/connection-bundle.ts index c73f8bfd..c898adf8 100644 --- a/src/features/chat/host/connection-bundle.ts +++ b/src/features/chat/host/connection-bundle.ts @@ -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) => Promise; - }; + 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 => { - 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); diff --git a/src/features/chat/host/runtime.ts b/src/features/chat/host/runtime.ts new file mode 100644 index 00000000..4f6a772b --- /dev/null +++ b/src/features/chat/host/runtime.ts @@ -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; + 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) }); + }, + }; +} diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 94900427..29d63950 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -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 { - 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 { diff --git a/src/features/chat/host/surface-handle.ts b/src/features/chat/host/surface-handle.ts new file mode 100644 index 00000000..b2c4dbb3 --- /dev/null +++ b/src/features/chat/host/surface-handle.ts @@ -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; + applyViewState(state: unknown): void; + open(): void; + close(): void; + refreshSettings(): void; + refreshSharedThreadList(): Promise; + applyThreadListSnapshot(threads: readonly Thread[]): void; + applyAppServerMetadataSnapshot(metadata: SharedServerMetadata): void; + applyAvailableModelsSnapshot(models: readonly ModelMetadata[]): void; + openPanelSnapshot(): OpenCodexPanelSnapshot; + openThread(threadId: string): Promise; + focusThread(threadId?: string | null): Promise; + focusComposer(): void; + notifyThreadArchived(threadId: string): void; + notifyThreadRenamed(threadId: string, name: string | null): void; + setComposerText(text: string): void; + connect(): Promise; + startNewThread(): Promise; +} diff --git a/src/features/chat/host/view.ts b/src/features/chat/host/view.ts index 1d5fa35f..2e8fd01e 100644 --- a/src/features/chat/host/view.ts +++ b/src/features/chat/host/view.ts @@ -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 { - return this.session.persistedState(); + return this.surface.persistedState(); } override async setState(state: unknown, result: ViewStateResult): Promise { await super.setState(state, result); - this.session.applyViewState(state); + this.surface.applyViewState(state); } override async onOpen(): Promise { - this.session.open(); + this.surface.open(); } override async onClose(): Promise { - this.session.close(); - } - - refreshSettings(): void { - this.session.refreshSettings(); - } - - refreshSharedThreadList(): Promise { - 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 { - return this.session.openThread(threadId); - } - - focusThread(threadId: string | null = null): Promise { - 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 { - return this.session.connect(); - } - - startNewThread(): Promise { - return this.session.startNewThread(); + this.surface.close(); } private refreshTabHeader(): void { diff --git a/src/features/thread-picker/modal.ts b/src/features/thread-picker/modal.ts index edcf13b0..919bd49d 100644 --- a/src/features/thread-picker/modal.ts +++ b/src/features/thread-picker/modal.ts @@ -11,8 +11,10 @@ export interface ThreadPickerHost { readonly app: App; readonly settings: CodexPanelSettings; readonly vaultPath: string; - cachedThreadList(): readonly Thread[] | null; - refreshThreadList(fetchThreads: () => Promise): Promise; + readonly threadCatalog: { + cachedThreads(): readonly Thread[] | null; + refreshThreads(fetchThreads: () => Promise): Promise; + }; openThreadInCurrentView(threadId: string): Promise; openThreadInAvailableView(threadId: string): Promise; } @@ -76,14 +78,14 @@ function threadOpenModeFromEvent(evt: MouseEvent | KeyboardEvent): ThreadOpenMod } async function loadThreadPickerThreads(host: ThreadPickerHost): Promise { - 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); }), { diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 3fb9d555..2cbc1f5c 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -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): Promise; + cachedThreads(): readonly Thread[] | null; + }; openNewPanel(): Promise; openThreadInAvailableView(threadId: string): Promise; getOpenPanelSnapshots(): OpenCodexPanelSnapshot[]; - notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; - notifyThreadRenamed(threadId: string, name: string | null): void; - refreshThreadList(fetchThreads: () => Promise): Promise; - 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 { 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(); diff --git a/src/features/threads/thread-operations.ts b/src/features/threads/thread-operations.ts new file mode 100644 index 00000000..74d8455d --- /dev/null +++ b/src/features/threads/thread-operations.ts @@ -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; + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/features/threads/thread-title-service.ts b/src/features/threads/thread-title-service.ts new file mode 100644 index 00000000..5cba57e8 --- /dev/null +++ b/src/features/threads/thread-title-service.ts @@ -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; +} + +export class ThreadTitleService { + constructor(private readonly host: ThreadTitleServiceHost) {} + + async generateTitle(threadId: string): Promise { + 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 { + 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 { + 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, + }); + } +} diff --git a/src/main.ts b/src/main.ts index 2365f5e1..6ee26e29 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { - 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 { - 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 { - 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 { @@ -126,120 +90,4 @@ export default class CodexPanelPlugin extends Plugin { async saveSettings(): Promise { 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): Promise { - 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[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); - }, - }; - } } diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts new file mode 100644 index 00000000..1a5a9901 --- /dev/null +++ b/src/plugin-runtime.ts @@ -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; +} + +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[0]): void { + this.panels.recordLastFocusedPanel(leaf); + } + + activatePanel(): Promise { + return this.panels.activateView(); + } + + activateNewPanel(): Promise { + return this.panels.activateNewView(); + } + + async startNewChat(): Promise { + const view = await this.panels.activateView(); + await view.surface.startNewThread(); + } + + openThreadPicker(): void { + void openThreadPicker(this.threadPickerHost()); + } + + async activateThreadsView(): Promise { + 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 { + 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, + }; + } +} diff --git a/src/settings/dynamic-data-controller.ts b/src/settings/dynamic-data-controller.ts index 35c3cd5c..40ee54eb 100644 --- a/src/settings/dynamic-data-controller.ts +++ b/src/settings/dynamic-data-controller.ts @@ -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, { diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index f3a8c877..29bcacac 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -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 { 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 { 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 { diff --git a/src/workspace/shared-thread-catalog.ts b/src/workspace/shared-thread-catalog.ts new file mode 100644 index 00000000..9ce9e984 --- /dev/null +++ b/src/workspace/shared-thread-catalog.ts @@ -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): Promise { + 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(); + } +} diff --git a/src/workspace/thread-surface-actions.ts b/src/workspace/thread-surface-actions.ts index 1ecf94f6..e22447f6 100644 --- a/src/workspace/thread-surface-actions.ts +++ b/src/workspace/thread-surface-actions.ts @@ -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(); }, diff --git a/tests/features/chat/threads/auto-title-controller.test.ts b/tests/features/chat/threads/auto-title-controller.test.ts index 1bcf4d40..e583edef 100644 --- a/tests/features/chat/threads/auto-title-controller.test.ts +++ b/tests/features/chat/threads/auto-title-controller.test.ts @@ -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[0]> = {}, -): ConstructorParameters[0] & { controller: AutoTitleController } { + overrides: { + currentClient?: () => AppServerClient; + generateThreadTitle?: (context: ThreadTitleContext) => Promise; + } = {}, +): ConstructorParameters[0] & { + controller: AutoTitleController; + notifyThreadRenamed: ReturnType; +} { 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[0]; - return { ...host, controller: new AutoTitleController(host) }; + return { ...host, notifyThreadRenamed, controller: new AutoTitleController(host) }; } function fakeClient(options: { setThreadName?: ReturnType } = {}): AppServerClient { @@ -153,4 +178,6 @@ function threadFixture(id: string): Thread { async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); } diff --git a/tests/features/chat/threads/rename-editor-controller.test.ts b/tests/features/chat/threads/rename-editor-controller.test.ts index 803fd79d..ca3023c4 100644 --- a/tests/features/chat/threads/rename-editor-controller.test.ts +++ b/tests/features/chat/threads/rename-editor-controller.test.ts @@ -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(); + const generatedTitle = deferred(); 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(); - const secondGeneratedTitle = deferred(); + const firstGeneratedTitle = deferred(); + const secondGeneratedTitle = deferred(); const generateThreadTitle = vi.fn().mockReturnValueOnce(firstGeneratedTitle.promise).mockReturnValueOnce(secondGeneratedTitle.promise); const { controller } = controllerFixture({ generateThreadTitle }); @@ -145,21 +144,40 @@ describe("ThreadRenameEditorController", () => { }); function controllerFixture( - overrides: Partial[0]> = {}, -): ConstructorParameters[0] & { controller: ThreadRenameEditorController } { + overrides: Partial[0], "ensureConnected" | "addSystemMessage">> & { + currentClient?: () => AppServerClient; + generateThreadTitle?: () => Promise; + } = {}, +): ConstructorParameters[0] & { + controller: ThreadRenameEditorController; + notifyThreadRenamed: ReturnType; +} { 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[0]; - return { ...host, controller: new ThreadRenameEditorController(host) }; + return { ...host, notifyThreadRenamed, controller: new ThreadRenameEditorController(host) }; } function fakeClient(options: { setThreadName?: ReturnType } = {}): AppServerClient { diff --git a/tests/features/chat/threads/thread-management-actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts index c4fb38b3..3dd1a3eb 100644 --- a/tests/features/chat/threads/thread-management-actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -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 { diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 2378a0cd..a90b43af 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -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) => fetchThreads() as Promise); + const refreshThreads = vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise); 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(".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(".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(".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) => fetchThreads() as Promise, - ) 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), }, }; diff --git a/tests/features/thread-picker/modal.test.ts b/tests/features/thread-picker/modal.test.ts index f8a72365..20022d50 100644 --- a/tests/features/thread-picker/modal.test.ts +++ b/tests/features/thread-picker/modal.test.ts @@ -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); }, diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 3281403a..b2fdc5e3 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -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) => fetchThreads() as Promise); + const refreshThreads = vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise); 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 = {}): Record = {}) { + 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 = {}) { openNewPanel: vi.fn().mockResolvedValue(undefined), openThreadInAvailableView: vi.fn().mockResolvedValue(undefined), getOpenPanelSnapshots: vi.fn(() => []), - notifyThreadArchived: vi.fn(), - notifyThreadRenamed: vi.fn(), - refreshThreadList: vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise), - cachedThreadList: vi.fn(() => null), - ...overrides, + threadCatalog: { + notifyThreadArchived: vi.fn(), + notifyThreadRenamed: vi.fn(), + refreshFromOpenSurface: vi.fn(), + refreshThreads: vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise), + cachedThreads: vi.fn(() => null), + ...threadCatalogOverrides, + }, + ...hostOverrides, }; } diff --git a/tests/features/threads/thread-operations.test.ts b/tests/features/threads/thread-operations.test.ts new file mode 100644 index 00000000..da0bfdb6 --- /dev/null +++ b/tests/features/threads/thread-operations.test.ts @@ -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; + +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), + }; +} diff --git a/tests/features/threads/thread-title-service.test.ts b/tests/features/threads/thread-title-service.test.ts new file mode 100644 index 00000000..355412b9 --- /dev/null +++ b/tests/features/threads/thread-title-service.test.ts @@ -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[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 }; +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 86d76351..20502067 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -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).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).mockReturnValue(activeView); (plugin.app.workspace.getMostRecentLeaf as ReturnType).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).mockReturnValue(currentLeaf); @@ -220,8 +220,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => { (plugin.app.workspace.getMostRecentLeaf as ReturnType).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).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).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) => fetchThreads() as Promise), - cachedThreadList: vi.fn(() => null), + refreshThreads: vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise), + cachedThreads: vi.fn(() => null), cachedAppServerMetadata: vi.fn(() => null), }, }; @@ -645,8 +645,8 @@ function thread(id: string): Thread { } function panelSnapshot( - overrides: Partial> = {}, -): ReturnType { + overrides: Partial> = {}, +): ReturnType { return { viewId: "view", threadId: "thread", diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 6959a3a1..52656878 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -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) => 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(), + }, }; } diff --git a/tests/workspace/shared-thread-catalog.test.ts b/tests/workspace/shared-thread-catalog.test.ts new file mode 100644 index 00000000..f3ce88b1 --- /dev/null +++ b/tests/workspace/shared-thread-catalog.test.ts @@ -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 { + return { + runtimeConfig: null, + availableModels: [], + availableSkills: [], + rateLimit: null, + serverDiagnostics: createServerDiagnostics(), + ...overrides, + }; +} diff --git a/tests/workspace/thread-surface-actions.test.ts b/tests/workspace/thread-surface-actions.test.ts index 717b7def..db86b6d5 100644 --- a/tests/workspace/thread-surface-actions.test.ts +++ b/tests/workspace/thread-surface-actions.test.ts @@ -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,