From c71024e11ceb97ff9124e52919e2558867694f0a Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 15 Jun 2026 14:44:31 +0900 Subject: [PATCH] Centralize thread rename and archive catalog mutations --- .../chat/app-server/inbound/controller.ts | 12 ++--- .../app-server/inbound/notification-plan.ts | 29 ++++-------- .../chat/application/ports/chat-host.ts | 4 +- .../threads/auto-title-controller.ts | 15 ++----- .../chat/application/threads/identity-sync.ts | 24 ++++------ .../threads/rename-editor-controller.ts | 15 +++---- .../threads/thread-management-actions.ts | 15 +------ src/features/chat/host/connection-bundle.ts | 10 ++--- src/features/chat/host/runtime.ts | 11 ++--- src/features/chat/host/session.ts | 8 ++-- src/features/chat/host/surface-handle.ts | 4 +- src/features/threads-view/session.ts | 28 +++++------- src/features/threads/thread-operations.ts | 27 ++++-------- src/plugin-runtime.ts | 16 +++---- src/workspace/shared-thread-catalog.ts | 30 +++++++++---- src/workspace/thread-surface-actions.ts | 44 +++++++++---------- .../chat/protocol/inbound/controller.test.ts | 37 +++++----------- .../threads/auto-title-controller.test.ts | 2 +- .../chat/threads/identity-sync.test.ts | 8 ++-- .../threads/rename-editor-controller.test.ts | 11 +++-- .../threads/thread-management-actions.test.ts | 11 +++-- tests/features/chat/view-connection.test.ts | 36 ++++++++------- tests/features/threads-view/view.test.ts | 16 +++---- .../threads/thread-operations.test.ts | 19 ++++---- tests/main.test.ts | 26 +++++------ tests/workspace/shared-thread-catalog.test.ts | 32 +++++++++----- .../workspace/thread-surface-actions.test.ts | 33 +++++++++++++- 27 files changed, 255 insertions(+), 268 deletions(-) diff --git a/src/features/chat/app-server/inbound/controller.ts b/src/features/chat/app-server/inbound/controller.ts index 43cfab4b..834d3272 100644 --- a/src/features/chat/app-server/inbound/controller.ts +++ b/src/features/chat/app-server/inbound/controller.ts @@ -40,8 +40,8 @@ export interface ChatInboundControllerActions { refreshSkills: (forceReload?: boolean) => void; publishAppServerMetadata: () => void; maybeNameThread: (threadId: string, turnId: string, completedSummary: ThreadConversationSummary | null) => void; - notifyThreadArchived: (threadId: string) => void; - notifyThreadRenamed: (threadId: string, name: string | null) => void; + applyThreadArchived: (threadId: string) => void; + applyThreadRenamed: (threadId: string, name: string | null) => void; recordMcpStartupStatus: (name: string, status: McpServerStartupStatus, message: string | null) => void; respondToServerRequest: (requestId: RequestId, result: unknown) => boolean; rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean; @@ -199,11 +199,11 @@ export class ChatInboundController { case "maybe-name-thread": this.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedSummary); return; - case "notify-thread-archived": - this.actions.notifyThreadArchived(effect.threadId); + case "apply-thread-archived": + this.actions.applyThreadArchived(effect.threadId); return; - case "notify-thread-renamed": - this.actions.notifyThreadRenamed(effect.threadId, effect.name); + case "apply-thread-renamed": + this.actions.applyThreadRenamed(effect.threadId, effect.name); return; case "record-mcp-startup-status": this.actions.recordMcpStartupStatus(effect.name, effect.status, effect.message); diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index 9bdc5ba9..2eb1bbf8 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -49,8 +49,8 @@ export type ChatNotificationEffect = | { type: "refresh-skills"; forceReload: boolean } | { type: "publish-app-server-metadata" } | { type: "maybe-name-thread"; threadId: string; turnId: string; completedSummary: ThreadConversationSummary | null } - | { type: "notify-thread-archived"; threadId: string } - | { type: "notify-thread-renamed"; threadId: string; name: string | null } + | { type: "apply-thread-archived"; threadId: string } + | { type: "apply-thread-renamed"; threadId: string; name: string | null } | { type: "record-mcp-startup-status"; name: string; @@ -248,29 +248,16 @@ const THREAD_LIFECYCLE_PLANNERS = { } return EMPTY_PLAN; }, - "thread/archived": (state, notification) => ({ - actions: [ - { - type: "thread-list/applied", - threads: state.threadList.listedThreads.filter((thread) => thread.id !== notification.params.threadId), - }, - ...(state.activeThread.id === notification.params.threadId ? ([{ type: "active-thread/cleared" }] satisfies ChatAction[]) : []), - ], - effects: [{ type: "notify-thread-archived", threadId: notification.params.threadId }], + "thread/archived": (_state, notification) => ({ + actions: [], + effects: [{ type: "apply-thread-archived", threadId: notification.params.threadId }], }), "thread/unarchived": () => ({ actions: [], effects: [{ type: "refresh-threads" }] }), - "thread/name/updated": (state, notification) => { + "thread/name/updated": (_state, notification) => { const name = normalizeExplicitThreadName(notification.params.threadName); return { - actions: [ - { - type: "thread-list/applied", - threads: state.threadList.listedThreads.map((thread) => - thread.id === notification.params.threadId ? { ...thread, name } : thread, - ), - }, - ], - effects: [{ type: "notify-thread-renamed", threadId: notification.params.threadId, name }], + actions: [], + effects: [{ type: "apply-thread-renamed", threadId: notification.params.threadId, name }], }; }, "thread/settings/updated": (state, notification) => { diff --git a/src/features/chat/application/ports/chat-host.ts b/src/features/chat/application/ports/chat-host.ts index 9d8484ad..8047a2f2 100644 --- a/src/features/chat/application/ports/chat-host.ts +++ b/src/features/chat/application/ports/chat-host.ts @@ -21,8 +21,8 @@ export interface WorkspacePanels { } export interface ThreadCatalogFacade { - notifyThreadArchived(threadId: string): void; - notifyThreadRenamed(threadId: string, name: string | null): void; + archiveThreadInCatalog(threadId: string): void; + renameThreadInCatalog(threadId: string, name: string | null): void; refreshThreadsViewLiveState(): void; refreshFromOpenSurface(): void; applyThreads(threads: readonly Thread[]): void; diff --git a/src/features/chat/application/threads/auto-title-controller.ts b/src/features/chat/application/threads/auto-title-controller.ts index 14d8c59a..03d84fbd 100644 --- a/src/features/chat/application/threads/auto-title-controller.ts +++ b/src/features/chat/application/threads/auto-title-controller.ts @@ -3,7 +3,7 @@ import type { ThreadConversationSummary } from "../../../../domain/threads/trans 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 { ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; export interface AutoTitleControllerHost { @@ -23,10 +23,6 @@ export class AutoTitleController { return this.host.stateStore.getState(); } - private dispatch(action: ChatAction): void { - this.host.stateStore.dispatch(action); - } - resetThreadTurnPresence(hadTurns: boolean): void { this.activeThreadHadTurns = hadTurns; } @@ -51,15 +47,10 @@ export class AutoTitleController { const title = await this.generateTitle(context); if (!title || !this.threadCanReceiveGeneratedTitle(threadId)) return; - const result = await this.host.operations.renameThread(threadId, title, { + const renamed = 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)), - }); + if (!renamed) return; } catch { // Auto-title is best-effort metadata. Leave the thread preview untouched on failure. } finally { diff --git a/src/features/chat/application/threads/identity-sync.ts b/src/features/chat/application/threads/identity-sync.ts index 70b1181b..cfe75f56 100644 --- a/src/features/chat/application/threads/identity-sync.ts +++ b/src/features/chat/application/threads/identity-sync.ts @@ -1,5 +1,5 @@ import type { RestorationController } from "./restoration-controller"; -import { activeThreadId, listedThreads } from "../state/selectors"; +import { activeThreadId } from "../state/selectors"; import type { ChatStateStore } from "../state/store"; export interface IdentitySyncHost { @@ -15,8 +15,8 @@ export interface IdentitySyncHost { export interface IdentitySync { clearActiveThreadContext: () => void; - notifyThreadArchived: (threadId: string) => void; - notifyThreadRenamed: (threadId: string, name: string | null) => void; + applyThreadArchived: (threadId: string) => void; + applyThreadRenamed: (threadId: string, name: string | null) => void; } export function createIdentitySync(host: IdentitySyncHost): IdentitySync { @@ -24,11 +24,11 @@ export function createIdentitySync(host: IdentitySyncHost): IdentitySync { clearActiveThreadContext: () => { clearActiveThreadContext(host); }, - notifyThreadArchived: (threadId) => { - notifyThreadArchived(host, threadId); + applyThreadArchived: (threadId) => { + applyThreadArchived(host, threadId); }, - notifyThreadRenamed: (threadId, name) => { - notifyThreadRenamed(host, threadId, name); + applyThreadRenamed: (threadId, name) => { + applyThreadRenamed(host, threadId, name); }, }; } @@ -43,19 +43,13 @@ function clearActiveThreadContext(host: IdentitySyncHost): void { host.refreshLiveState(); } -function notifyThreadArchived(host: IdentitySyncHost, threadId: string): void { +function applyThreadArchived(host: IdentitySyncHost, threadId: string): void { if (activeThreadId(host.stateStore.getState()) !== threadId) return; clearActiveThreadContext(host); } -function notifyThreadRenamed(host: IdentitySyncHost, threadId: string, name: string | null): void { +function applyThreadRenamed(host: IdentitySyncHost, threadId: string, name: string | null): void { let changed = false; - const renamedThreads = listedThreads(host.stateStore.getState()).map((thread) => { - if (thread.id !== threadId) return thread; - changed = true; - return { ...thread, name }; - }); - host.stateStore.dispatch({ type: "thread-list/applied", threads: renamedThreads }); const restoredThread = host.restoration.placeholder(); if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) { host.restoration.rename(threadId, name); diff --git a/src/features/chat/application/threads/rename-editor-controller.ts b/src/features/chat/application/threads/rename-editor-controller.ts index e5a04f5e..1e09c3de 100644 --- a/src/features/chat/application/threads/rename-editor-controller.ts +++ b/src/features/chat/application/threads/rename-editor-controller.ts @@ -1,6 +1,5 @@ import { getThreadTitle } from "../../../../domain/threads/model"; import type { Thread } from "../../../../domain/threads/model"; -import { threadRenameFromValue } from "../../../../app-server/services/thread-rename"; import type { ThreadTitleContext } from "../../../../domain/threads/title-generation-model"; import type { ThreadOperations } from "../../../threads/thread-operations"; import type { ThreadTitleService } from "../../../threads/thread-title-service"; @@ -74,17 +73,17 @@ export class ThreadRenameEditorController { async save(threadId: string, value: string): Promise { if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId || this.renameState.kind === "generating") return; const editingState = this.renameState; - const rename = threadRenameFromValue(value); - if (!rename) { - this.cancel(threadId); - return; - } await this.host.ensureConnected(); if (this.renameState !== editingState) return; - if (await this.host.operations.renameThread(threadId, rename.name)) { - if (this.renameState === editingState) this.clear(); + const result = await this.host.operations.renameThread(threadId, value); + if (!result) { + if (this.renameState === editingState) this.cancel(threadId); + return; + } + if (this.renameState === editingState) { + this.clear(); } } diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index ffc17b22..b919ac1c 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -1,7 +1,6 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; import { rollbackThread as rollbackThreadOnAppServer } from "../../../../app-server/services/threads"; import { inheritedForkThreadName } from "../../../../domain/threads/model"; -import { threadRenameFromValue } from "../../../../app-server/services/thread-rename"; import type { ThreadOperations } from "../../../threads/thread-operations"; import { archivedSourceOpenForkFailedMessage, @@ -127,9 +126,7 @@ async function forkThreadFromTurn( if (!threadManagementStillTargetsOriginalPanel(threadManagementState(host), initialActiveThreadId, threadId)) return; if (sourceName) { try { - const rename = threadRenameFromValue(sourceName); - if (!rename) return; - await host.operations.renameThread(forkedThreadId, rename.name); + if (!(await host.operations.renameThread(forkedThreadId, sourceName))) return; } catch (error) { const message = error instanceof Error ? error.message : String(error); host.addSystemMessage(forkNameCopyFailedMessage(forkedThreadId, message)); @@ -157,17 +154,9 @@ async function forkThreadFromTurn( } async function renameThread(host: ThreadManagementActionsHost, threadId: string, value: string): Promise { - const rename = threadRenameFromValue(value); - if (!rename) return false; - try { - const result = await host.operations.renameThread(threadId, rename.name); + const result = await host.operations.renameThread(threadId, value); 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)), - }); return true; } catch (error) { host.addSystemMessage(error instanceof Error ? error.message : String(error)); diff --git a/src/features/chat/host/connection-bundle.ts b/src/features/chat/host/connection-bundle.ts index c898adf8..93fc1406 100644 --- a/src/features/chat/host/connection-bundle.ts +++ b/src/features/chat/host/connection-bundle.ts @@ -43,7 +43,7 @@ export interface ChatConnectionBundleContext { deferredTasks: ChatViewDeferredTasks; threadCatalog: Pick< ThreadCatalogFacade, - "applyThreads" | "publishAppServerMetadata" | "refreshThreads" | "notifyThreadArchived" | "notifyThreadRenamed" + "applyThreads" | "publishAppServerMetadata" | "refreshThreads" | "archiveThreadInCatalog" | "renameThreadInCatalog" >; goalSync: ThreadGoalSyncActions; autoTitle: AutoTitleController; @@ -108,11 +108,11 @@ export function createChatConnectionBundle(context: ChatConnectionBundleContext) maybeNameThread: (threadId, turnId, completedSummary) => { autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary); }, - notifyThreadArchived: (threadId) => { - threadCatalog.notifyThreadArchived(threadId); + applyThreadArchived: (threadId) => { + threadCatalog.archiveThreadInCatalog(threadId); }, - notifyThreadRenamed: (threadId, name) => { - threadCatalog.notifyThreadRenamed(threadId, name); + applyThreadRenamed: (threadId, name) => { + threadCatalog.renameThreadInCatalog(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 index 4f6a772b..714a127f 100644 --- a/src/features/chat/host/runtime.ts +++ b/src/features/chat/host/runtime.ts @@ -143,14 +143,11 @@ export function createChatPanelRuntime(context: ChatPanelRuntimeContext): ChatPa }, archiveAdapter: environment.obsidian.archiveAdapter, catalog: { - notifyThreadArchived: (threadId) => { - environment.plugin.threadCatalog.notifyThreadArchived(threadId); + archiveThreadInCatalog: (threadId) => { + environment.plugin.threadCatalog.archiveThreadInCatalog(threadId); }, - notifyThreadRenamed: (threadId, name) => { - environment.plugin.threadCatalog.notifyThreadRenamed(threadId, name); - }, - refreshFromOpenSurface: () => { - environment.plugin.threadCatalog.refreshFromOpenSurface(); + renameThreadInCatalog: (threadId, name) => { + environment.plugin.threadCatalog.renameThreadInCatalog(threadId, name); }, }, notice: (text) => { diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 29d63950..68e7e04c 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -194,12 +194,12 @@ export class ChatPanelSession implements ChatSurfaceHandle { this.parts.composer.controller.focus(); } - notifyThreadArchived(threadId: string): void { - this.parts.thread.identity.notifyThreadArchived(threadId); + applyThreadArchived(threadId: string): void { + this.parts.thread.identity.applyThreadArchived(threadId); } - notifyThreadRenamed(threadId: string, name: string | null): void { - this.parts.thread.identity.notifyThreadRenamed(threadId, name); + applyThreadRenamed(threadId: string, name: string | null): void { + this.parts.thread.identity.applyThreadRenamed(threadId, name); } open(): void { diff --git a/src/features/chat/host/surface-handle.ts b/src/features/chat/host/surface-handle.ts index b2c4dbb3..da8cf988 100644 --- a/src/features/chat/host/surface-handle.ts +++ b/src/features/chat/host/surface-handle.ts @@ -18,8 +18,8 @@ export interface ChatSurfaceHandle { openThread(threadId: string): Promise; focusThread(threadId?: string | null): Promise; focusComposer(): void; - notifyThreadArchived(threadId: string): void; - notifyThreadRenamed(threadId: string, name: string | null): void; + applyThreadArchived(threadId: string): void; + applyThreadRenamed(threadId: string, name: string | null): void; setComposerText(text: string): void; connect(): Promise; startNewThread(): Promise; diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 2cbc1f5c..62e12b17 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -7,7 +7,6 @@ import type { Thread } from "../../domain/threads/model"; import type { CodexPanelSettings } from "../../settings/model"; import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot"; import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown"; -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"; @@ -36,8 +35,8 @@ 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; + archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void; + renameThreadInCatalog(threadId: string, name: string | null): void; refreshFromOpenSurface(): void; refreshThreads(fetchThreads: () => Promise): Promise; cachedThreads(): readonly Thread[] | null; @@ -89,14 +88,11 @@ export class CodexThreadsSession { }, archiveAdapter: () => this.environment.archiveAdapter(), catalog: { - notifyThreadArchived: (threadId, options) => { - this.host.threadCatalog.notifyThreadArchived(threadId, options); + archiveThreadInCatalog: (threadId, options) => { + this.host.threadCatalog.archiveThreadInCatalog(threadId, options); }, - notifyThreadRenamed: (threadId, name) => { - this.host.threadCatalog.notifyThreadRenamed(threadId, name); - }, - refreshFromOpenSurface: () => { - this.host.threadCatalog.refreshFromOpenSurface(); + renameThreadInCatalog: (threadId, name) => { + this.host.threadCatalog.renameThreadInCatalog(threadId, name); }, }, notice: (message) => { @@ -329,16 +325,14 @@ export class CodexThreadsSession { private async saveRename(threadId: string, value: string): Promise { const editingState = this.renameStates.get(threadId); if (!editingState || editingState.kind === "generating") return; - const rename = threadRenameFromValue(value); - if (!rename) { - this.cancelRename(threadId); - return; - } try { await this.ensureConnected(); if (this.renameStates.get(threadId) !== editingState) return; - const result = await this.operations.renameThread(threadId, rename.name); - if (!result) return; + const result = await this.operations.renameThread(threadId, value); + if (!result) { + this.cancelRename(threadId); + return; + } this.renameStates.delete(threadId); } catch (error) { this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; diff --git a/src/features/threads/thread-operations.ts b/src/features/threads/thread-operations.ts index 74d8455d..e090e296 100644 --- a/src/features/threads/thread-operations.ts +++ b/src/features/threads/thread-operations.ts @@ -15,9 +15,8 @@ export interface ThreadOperationsHost { }; archiveAdapter(): ArchiveExportAdapter; catalog: { - notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; - notifyThreadRenamed(threadId: string, name: string | null): void; - refreshFromOpenSurface(): void; + archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void; + renameThreadInCatalog(threadId: string, name: string | null): void; }; notice(message: string): void; } @@ -27,10 +26,6 @@ export interface ArchiveThreadOptions { closeOpenPanels?: boolean; } -export interface RenameThreadResult { - name: string; -} - export interface RenameThreadOptions { shouldPublish?: () => boolean; } @@ -38,27 +33,23 @@ export interface RenameThreadOptions { export class ThreadOperations { constructor(private readonly host: ThreadOperationsHost) {} - async renameThread(threadId: string, value: string, options: RenameThreadOptions = {}): Promise { + async renameThread(threadId: string, value: string, options: RenameThreadOptions = {}): Promise { const rename = threadRenameFromValue(value); - if (!rename) return null; + if (!rename) return false; await this.host.connection.ensureConnected(); return this.renameConnectedThread(threadId, rename, options); } - private async renameConnectedThread( - threadId: string, - rename: ThreadRename, - options: RenameThreadOptions = {}, - ): Promise { + private async renameConnectedThread(threadId: string, rename: ThreadRename, options: RenameThreadOptions = {}): Promise { const client = this.host.connection.currentClient(); - if (!client) return null; + if (!client) return false; const result = await renameThreadOnAppServer(client, threadId, rename); if (options.shouldPublish?.() ?? true) { - this.host.catalog.notifyThreadRenamed(threadId, result.name); + this.host.catalog.renameThreadInCatalog(threadId, result.name); } - return { name: result.name }; + return true; } async archiveThread(threadId: string, options: ArchiveThreadOptions = {}): Promise { @@ -77,7 +68,7 @@ export class ThreadOperations { this.host.notice(`Saved archived thread to ${result.exportedPath}.`); } const notificationOptions = options.closeOpenPanels === undefined ? undefined : { closeOpenPanels: options.closeOpenPanels }; - this.host.catalog.notifyThreadArchived(threadId, notificationOptions); + this.host.catalog.archiveThreadInCatalog(threadId, notificationOptions); return result; } } diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 1a5a9901..fbe43a38 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -96,11 +96,11 @@ export class CodexPanelRuntime { openTurnDiff: (state) => this.openTurnDiff(state), }, threadCatalog: { - notifyThreadArchived: (threadId) => { - this.threadCatalog.notifyThreadArchived(threadId); + archiveThreadInCatalog: (threadId) => { + this.threadCatalog.archiveThreadInCatalog(threadId); }, - notifyThreadRenamed: (threadId, name) => { - this.threadCatalog.notifyThreadRenamed(threadId, name); + renameThreadInCatalog: (threadId, name) => { + this.threadCatalog.renameThreadInCatalog(threadId, name); }, refreshThreadsViewLiveState: () => { this.threadCatalog.refreshThreadsViewLiveState(); @@ -126,11 +126,11 @@ export class CodexPanelRuntime { settings: this.options.settingsRef.settings, vaultPath: this.options.settingsRef.vaultPath, threadCatalog: { - notifyThreadArchived: (threadId, options) => { - this.threadCatalog.notifyThreadArchived(threadId, options); + archiveThreadInCatalog: (threadId, options) => { + this.threadCatalog.archiveThreadInCatalog(threadId, options); }, - notifyThreadRenamed: (threadId, name) => { - this.threadCatalog.notifyThreadRenamed(threadId, name); + renameThreadInCatalog: (threadId, name) => { + this.threadCatalog.renameThreadInCatalog(threadId, name); }, refreshFromOpenSurface: () => { this.threadCatalog.refreshFromOpenSurface(); diff --git a/src/workspace/shared-thread-catalog.ts b/src/workspace/shared-thread-catalog.ts index 9ce9e984..1c53322b 100644 --- a/src/workspace/shared-thread-catalog.ts +++ b/src/workspace/shared-thread-catalog.ts @@ -48,21 +48,33 @@ export class SharedThreadCatalog { } refreshFromOpenSurface(): void { - this.options.surfaces.refreshSharedThreadListFromOpenSurface(); + this.invalidateThreadsFromOpenSurface(); + } + + invalidateThreadsFromOpenSurface(): void { + this.options.surfaces.invalidateThreadsFromOpenSurface(); + } + + renameThreadInCatalog(threadId: string, name: string | null): void { + const threads = this.cachedThreads(); + if (threads) { + this.applyThreads(threads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread))); + } + this.options.surfaces.applyThreadRenamed(threadId, name); + } + + archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void { + const threads = this.cachedThreads(); + if (threads) { + this.applyThreads(threads.filter((thread) => thread.id !== threadId)); + } + this.options.surfaces.applyThreadArchived(threadId, options); } 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 e22447f6..d1b55028 100644 --- a/src/workspace/thread-surface-actions.ts +++ b/src/workspace/thread-surface-actions.ts @@ -14,13 +14,13 @@ export interface ThreadSurfaceActionsOptions { export interface ThreadSurfaceActions { refreshOpenViews(): void; - refreshSharedThreadListFromOpenSurface(): void; + invalidateThreadsFromOpenSurface(): void; applyThreadListSnapshot(threads: readonly Thread[]): void; + applyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; + applyThreadRenamed(threadId: string, name: string | null): void; publishAppServerMetadata(metadata: SharedServerMetadata): void; publishModels(models: readonly ModelMetadata[]): void; refreshThreadsViewLiveState(): void; - notifyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; - notifyThreadRenamed(threadId: string, name: string | null): void; } export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions): ThreadSurfaceActions { @@ -29,7 +29,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions) .getLeavesOfType(VIEW_TYPE_CODEX_THREADS) .flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : [])); - const refreshSharedThreadListFromOpenSurface = (): void => { + const invalidateThreadsFromOpenSurface = (): void => { const chatView = options.panels.panelViews().find((view) => view.surface.openPanelSnapshot().connected); if (chatView) { void chatView.surface.refreshSharedThreadList(); @@ -47,7 +47,7 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions) } }, - refreshSharedThreadListFromOpenSurface, + invalidateThreadsFromOpenSurface, applyThreadListSnapshot(threads: readonly Thread[]): void { for (const view of options.panels.panelViews()) { @@ -58,6 +58,22 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions) } }, + applyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void { + const leavesToClose = archiveOptions.closeOpenPanels ? options.panels.panelLeavesForThread(threadId) : []; + for (const view of options.panels.panelViews()) { + view.surface.applyThreadArchived(threadId); + } + for (const leaf of leavesToClose) { + leaf.detach(); + } + }, + + applyThreadRenamed(threadId: string, name: string | null): void { + for (const view of options.panels.panelViews()) { + view.surface.applyThreadRenamed(threadId, name); + } + }, + publishAppServerMetadata(metadata: SharedServerMetadata): void { for (const view of options.panels.panelViews()) { view.surface.applyAppServerMetadataSnapshot(metadata); @@ -75,23 +91,5 @@ export function createThreadSurfaceActions(options: ThreadSurfaceActionsOptions) view.refreshLiveState(); } }, - - notifyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void { - const leavesToClose = archiveOptions.closeOpenPanels ? options.panels.panelLeavesForThread(threadId) : []; - for (const view of options.panels.panelViews()) { - view.surface.notifyThreadArchived(threadId); - } - for (const leaf of leavesToClose) { - leaf.detach(); - } - refreshSharedThreadListFromOpenSurface(); - }, - - notifyThreadRenamed(threadId: string, name: string | null): void { - for (const view of options.panels.panelViews()) { - view.surface.notifyThreadRenamed(threadId, name); - } - refreshSharedThreadListFromOpenSurface(); - }, }; } diff --git a/tests/features/chat/protocol/inbound/controller.test.ts b/tests/features/chat/protocol/inbound/controller.test.ts index 290817a4..71110d74 100644 --- a/tests/features/chat/protocol/inbound/controller.test.ts +++ b/tests/features/chat/protocol/inbound/controller.test.ts @@ -3,9 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { ChatInboundController } from "../../../../../src/features/chat/app-server/inbound/controller"; import { attachHookRunsToTurn } from "../../../../../src/features/chat/domain/message-stream/updates"; import { - activeTurnId, chatReducer, - chatTurnBusy, createChatState, pendingTurnStart, type ChatAction, @@ -29,8 +27,8 @@ function controllerForState( refreshSkills: vi.fn(), publishAppServerMetadata: vi.fn(), maybeNameThread: vi.fn(), - notifyThreadArchived: vi.fn(), - notifyThreadRenamed: vi.fn(), + applyThreadArchived: vi.fn(), + applyThreadRenamed: vi.fn(), recordMcpStartupStatus: vi.fn(), respondToServerRequest: vi.fn(() => true), rejectServerRequest: vi.fn(() => true), @@ -1038,29 +1036,16 @@ describe("ChatInboundController", () => { }, ]; state.requests.userInputDrafts = new Map([["20:note", "draft"]]); - const notifyThreadArchived = vi.fn(); - const controller = controllerForState(state, { notifyThreadArchived }); + const applyThreadArchived = vi.fn(); + const controller = controllerForState(state, { applyThreadArchived }); controller.handleNotification({ method: "thread/archived", params: { threadId: "thread-active" }, } satisfies Extract); - expect(state.activeThread.id).toBeNull(); - expect(activeTurnId(state)).toBeNull(); - expect(state.runtime.activeModel).toBeNull(); - expect(state.runtime.activeServiceTier).toBeNull(); - expect(state.activeThread.tokenUsage).toBeNull(); - expect(state.messageStream.historyCursor).toBeNull(); - expect(state.messageStream.loadingHistory).toBe(false); - expect(chatStateMessageStreamItems(state)).toEqual([]); - expect(state.messageStream.turnDiffs.size).toBe(0); - expect(state.composer.draft).toBe(""); - expect(chatTurnBusy(state)).toBe(false); - expect(state.requests.approvals).toEqual([]); - expect(state.requests.pendingUserInputs).toEqual([]); - expect(state.requests.userInputDrafts.size).toBe(0); - expect(notifyThreadArchived).toHaveBeenCalledWith("thread-active"); + expect(state.activeThread.id).toBe("thread-active"); + expect(applyThreadArchived).toHaveBeenCalledWith("thread-active"); }); it("does not replace the active cwd from unrelated thread-started notifications", () => { @@ -1323,20 +1308,20 @@ describe("ChatInboundController", () => { }); }); - it("updates listed thread names from thread name notifications", () => { + it("routes thread name notifications through catalog mutation effects", () => { const state = createChatState(); state.activeThread.id = "thread-active"; state.threadList.listedThreads = [panelThread("thread-active")]; - const notifyThreadRenamed = vi.fn(); - const controller = controllerForState(state, { notifyThreadRenamed }); + const applyThreadRenamed = vi.fn(); + const controller = controllerForState(state, { applyThreadRenamed }); controller.handleNotification({ method: "thread/name/updated", params: { threadId: "thread-active", threadName: " Codex Panel自動命名 " }, } satisfies Extract); - expect(state.threadList.listedThreads[0]?.name).toBe("Codex Panel自動命名"); - expect(notifyThreadRenamed).toHaveBeenCalledWith("thread-active", "Codex Panel自動命名"); + expect(state.threadList.listedThreads[0]?.name).toBeNull(); + expect(applyThreadRenamed).toHaveBeenCalledWith("thread-active", "Codex Panel自動命名"); }); it("syncs active runtime state from thread settings notifications", () => { diff --git a/tests/features/chat/threads/auto-title-controller.test.ts b/tests/features/chat/threads/auto-title-controller.test.ts index e583edef..a584b4d8 100644 --- a/tests/features/chat/threads/auto-title-controller.test.ts +++ b/tests/features/chat/threads/auto-title-controller.test.ts @@ -150,7 +150,7 @@ function controllerFixture( renameThread: async (threadId: string, value: string, options?: { shouldPublish?: () => boolean }) => { await currentClient().setThreadName(threadId, value); if (options?.shouldPublish?.() ?? true) notifyThreadRenamed(threadId, value); - return { name: value }; + return true; }, }, titleService, diff --git a/tests/features/chat/threads/identity-sync.test.ts b/tests/features/chat/threads/identity-sync.test.ts index a40b80fb..548a9886 100644 --- a/tests/features/chat/threads/identity-sync.test.ts +++ b/tests/features/chat/threads/identity-sync.test.ts @@ -56,7 +56,7 @@ describe("createIdentitySync", () => { activePermissionProfile: null, }); - controller.notifyThreadArchived("thread"); + controller.applyThreadArchived("thread"); expect(stateStore.getState().activeThread.id).toBeNull(); expect(host.invalidateResumeWork).toHaveBeenCalledOnce(); @@ -64,7 +64,7 @@ describe("createIdentitySync", () => { expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); }); - it("updates listed and restored thread titles on rename notifications", () => { + it("updates restored thread titles on rename notifications", () => { const { controller, restoredPlaceholder, restoredRename, stateStore } = createController(); stateStore.dispatch({ type: "thread-list/applied", threads: [thread("thread", "Old")] }); restoredPlaceholder.mockReturnValue({ @@ -75,9 +75,9 @@ describe("createIdentitySync", () => { loading: null, }); - controller.notifyThreadRenamed("thread", "New"); + controller.applyThreadRenamed("thread", "New"); - expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("New"); + expect(stateStore.getState().threadList.listedThreads[0]?.name).toBe("Old"); expect(restoredRename).toHaveBeenCalledWith("thread", "New"); }); }); diff --git a/tests/features/chat/threads/rename-editor-controller.test.ts b/tests/features/chat/threads/rename-editor-controller.test.ts index ca3023c4..12f65e09 100644 --- a/tests/features/chat/threads/rename-editor-controller.test.ts +++ b/tests/features/chat/threads/rename-editor-controller.test.ts @@ -5,6 +5,7 @@ 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 { threadRenameFromValue } from "../../../../src/app-server/services/thread-rename"; import { deferred } from "../../../support/async"; describe("ThreadRenameEditorController", () => { @@ -162,15 +163,17 @@ function controllerFixture( addSystemMessage: overrides.addSystemMessage ?? vi.fn(), operations: { renameThread: async (threadId: string, value: string) => { - await currentClient().setThreadName(threadId, value); + const rename = threadRenameFromValue(value); + if (!rename) return false; + await currentClient().setThreadName(threadId, rename.name); stateStore.dispatch({ type: "thread-list/applied", threads: stateStore .getState() - .threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: value } : thread)), + .threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: rename.name } : thread)), }); - notifyThreadRenamed(threadId, value); - return { name: value }; + notifyThreadRenamed(threadId, rename.name); + return true; }, }, titleService: { diff --git a/tests/features/chat/threads/thread-management-actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts index 3dd1a3eb..dab334e4 100644 --- a/tests/features/chat/threads/thread-management-actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -4,6 +4,7 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie 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 { threadRenameFromValue } from "../../../../src/app-server/services/thread-rename"; import { createChatState } from "../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import { @@ -261,7 +262,7 @@ describe("thread management actions", () => { expect(host.ensureConnected).toHaveBeenCalledOnce(); expect(client.setThreadName).toHaveBeenCalledWith("thread", "Slash command title"); - expect(host.stateStore.getState().threadList.listedThreads[0]?.name).toBe("Slash command title"); + expect(host.stateStore.getState().threadList.listedThreads[0]?.name).toBe("Old"); expect(host.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Slash command title"); }); @@ -451,10 +452,12 @@ function hostMock({ return result; }), renameThread: vi.fn(async (threadId: string, value: string) => { + const rename = threadRenameFromValue(value); + if (!rename) return false; await ensureConnected(); - await client.setThreadName(threadId, value); - notifyThreadRenamed(threadId, value); - return { name: value }; + await client.setThreadName(threadId, rename.name); + notifyThreadRenamed(threadId, rename.name); + return true; }), }, showNotice, diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index a90b43af..51aafca1 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -633,9 +633,9 @@ describe("CodexChatView connection lifecycle", () => { }); it("routes slash archive through shared panel notifications", async () => { - const notifyThreadArchived = vi.fn(); + const archiveThreadInCatalog = vi.fn(); const host = chatHost({ - notifyThreadArchived, + archiveThreadInCatalog, }); const client = connectedClient({ listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), @@ -649,12 +649,12 @@ describe("CodexChatView connection lifecycle", () => { await submitComposerByEnter(view); expect(client.archiveThread).toHaveBeenCalledWith("thread-1"); - expect(notifyThreadArchived).toHaveBeenCalledWith("thread-1"); + expect(archiveThreadInCatalog).toHaveBeenCalledWith("thread-1"); }); it("replaces the current panel with the forked thread after fork and archive", async () => { - const notifyThreadArchived = vi.fn(); - const host = chatHost({ notifyThreadArchived }); + const archiveThreadInCatalog = vi.fn(); + const host = chatHost({ archiveThreadInCatalog }); const client = connectedClient({ listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("source")] }), resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))), @@ -682,7 +682,7 @@ describe("CodexChatView connection lifecycle", () => { expect(client.resumeThread).toHaveBeenLastCalledWith("forked", "/vault"); expect(view.getState()).toEqual({ version: 1, threadId: "forked", threadTitle: "Restored thread" }); expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "forked" }); - expect(notifyThreadArchived).toHaveBeenCalledWith("source"); + expect(archiveThreadInCatalog).toHaveBeenCalledWith("source"); }); }); @@ -693,7 +693,7 @@ describe("CodexChatView connection lifecycle", () => { const view = await chatView({ requestSaveLayout }); await view.surface.openThread("thread-1"); - view.surface.notifyThreadArchived("thread-1"); + view.surface.applyThreadArchived("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.surface.notifyThreadRenamed("thread-1", "After rename"); + view.surface.applyThreadRenamed("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,8 @@ describe("CodexChatView connection lifecycle", () => { expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task..."); - view.surface.notifyThreadRenamed("thread-1", "Explicit name"); + view.surface.applyThreadListSnapshot([panelThread({ id: "thread-1", name: "Explicit name" })]); + view.surface.applyThreadRenamed("thread-1", "Explicit name"); await waitForAsyncWork(() => { expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Explicit name”..."); @@ -758,13 +759,15 @@ describe("CodexChatView connection lifecycle", () => { await view.onOpen(); await view.surface.openThread("thread-1"); - view.surface.notifyThreadRenamed("thread-1", "Renamed thread"); + view.surface.applyThreadListSnapshot([panelThread({ id: "thread-1", name: "Renamed thread" })]); + view.surface.applyThreadRenamed("thread-1", "Renamed thread"); await waitForAsyncWork(() => { expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Renamed thread”..."); }); - view.surface.notifyThreadRenamed("thread-1", null); + view.surface.applyThreadListSnapshot([panelThread({ id: "thread-1", name: null })]); + view.surface.applyThreadRenamed("thread-1", null); await waitForAsyncWork(() => { expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task..."); @@ -785,7 +788,8 @@ describe("CodexChatView connection lifecycle", () => { }); composer.setSelectionRange(5, 9); - view.surface.notifyThreadRenamed("thread-1", "Renamed thread"); + view.surface.applyThreadListSnapshot([panelThread({ id: "thread-1", name: "Renamed thread" })]); + view.surface.applyThreadRenamed("thread-1", "Renamed thread"); await waitForAsyncWork(() => { expect(composerElement(view)).toBe(composer); @@ -1282,8 +1286,8 @@ interface ChatHostFixtureOverrides { openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"]; focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"]; openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"]; - notifyThreadArchived?: CodexChatHost["threadCatalog"]["notifyThreadArchived"]; - notifyThreadRenamed?: CodexChatHost["threadCatalog"]["notifyThreadRenamed"]; + archiveThreadInCatalog?: CodexChatHost["threadCatalog"]["archiveThreadInCatalog"]; + renameThreadInCatalog?: CodexChatHost["threadCatalog"]["renameThreadInCatalog"]; refreshFromOpenSurface?: CodexChatHost["threadCatalog"]["refreshFromOpenSurface"]; refreshThreadsViewLiveState?: CodexChatHost["threadCatalog"]["refreshThreadsViewLiveState"]; applyThreads?: CodexChatHost["threadCatalog"]["applyThreads"]; @@ -1311,8 +1315,8 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost { openTurnDiff: overrides.openTurnDiff ?? vi.fn(), }, threadCatalog: { - notifyThreadArchived: overrides.notifyThreadArchived ?? vi.fn(), - notifyThreadRenamed: overrides.notifyThreadRenamed ?? vi.fn(), + archiveThreadInCatalog: overrides.archiveThreadInCatalog ?? vi.fn(), + renameThreadInCatalog: overrides.renameThreadInCatalog ?? vi.fn(), refreshFromOpenSurface: overrides.refreshFromOpenSurface ?? vi.fn(), refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(), applyThreads: overrides.applyThreads ?? vi.fn(), diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index b2fdc5e3..84f7bb5d 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -282,9 +282,9 @@ describe("CodexThreadsView", () => { listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), archiveThread, }); - const notifyThreadArchived = vi.fn(); + const archiveThreadInCatalog = vi.fn(); const host = threadsHost({ - threadCatalog: { notifyThreadArchived }, + threadCatalog: { archiveThreadInCatalog }, }); const view = await threadsView(host); @@ -294,7 +294,7 @@ describe("CodexThreadsView", () => { await waitForAsyncWork(() => { expect(archiveThread).toHaveBeenCalledWith("thread"); - expect(notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); + expect(archiveThreadInCatalog).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); }); }); @@ -304,9 +304,9 @@ describe("CodexThreadsView", () => { listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), setThreadName, }); - const notifyThreadRenamed = vi.fn(); + const renameThreadInCatalog = vi.fn(); const host = threadsHost({ - threadCatalog: { notifyThreadRenamed }, + threadCatalog: { renameThreadInCatalog }, }); const view = await threadsView(host); @@ -320,7 +320,7 @@ describe("CodexThreadsView", () => { await waitForAsyncWork(() => { expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread"); - expect(notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread"); + expect(renameThreadInCatalog).toHaveBeenCalledWith("thread", "Renamed thread"); }); }); @@ -424,8 +424,8 @@ function threadsHost(overrides: Record = {}) { openThreadInAvailableView: vi.fn().mockResolvedValue(undefined), getOpenPanelSnapshots: vi.fn(() => []), threadCatalog: { - notifyThreadArchived: vi.fn(), - notifyThreadRenamed: vi.fn(), + archiveThreadInCatalog: vi.fn(), + renameThreadInCatalog: vi.fn(), refreshFromOpenSurface: vi.fn(), refreshThreads: vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise), cachedThreads: vi.fn(() => null), diff --git a/tests/features/threads/thread-operations.test.ts b/tests/features/threads/thread-operations.test.ts index da0bfdb6..204f4bed 100644 --- a/tests/features/threads/thread-operations.test.ts +++ b/tests/features/threads/thread-operations.test.ts @@ -18,10 +18,10 @@ 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" }); + await expect(operations.renameThread("thread", " Saved title ")).resolves.toBe(true); expect(client?.setThreadName).toHaveBeenCalledWith("thread", "Saved title"); - expect(catalog.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Saved title"); + expect(catalog.renameThreadInCatalog).toHaveBeenCalledWith("thread", "Saved title"); }); it("can skip rename publication when the caller invalidates the save", async () => { @@ -29,7 +29,7 @@ describe("ThreadOperations", () => { await operations.renameThread("thread", "Generated title", { shouldPublish: () => false }); - expect(catalog.notifyThreadRenamed).not.toHaveBeenCalled(); + expect(catalog.renameThreadInCatalog).not.toHaveBeenCalled(); }); it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => { @@ -41,17 +41,17 @@ describe("ThreadOperations", () => { }); expect(notice).toHaveBeenCalledWith("Saved archived thread to Archive/thread.md."); - expect(catalog.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); + expect(catalog.archiveThreadInCatalog).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.renameThread("thread", "Title")).resolves.toBe(false); await expect(operations.archiveThread("thread")).resolves.toBeNull(); - expect(catalog.notifyThreadRenamed).not.toHaveBeenCalled(); - expect(catalog.notifyThreadArchived).not.toHaveBeenCalled(); + expect(catalog.renameThreadInCatalog).not.toHaveBeenCalled(); + expect(catalog.archiveThreadInCatalog).not.toHaveBeenCalled(); }); }); @@ -60,9 +60,8 @@ function operationsFixture(options: { client?: MockClient | null } = {}) { 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(), + archiveThreadInCatalog: vi.fn(), + renameThreadInCatalog: vi.fn(), }; const notice = vi.fn(); const host: ThreadOperationsHost = { diff --git a/tests/main.test.ts b/tests/main.test.ts index 20502067..4c9c5327 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -371,7 +371,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { expect(openThread).not.toHaveBeenCalled(); }); - it("refreshes shared thread lists after archive lifecycle notifications", async () => { + it("does not refresh shared thread lists after known archive mutations", async () => { const { CodexChatView } = await import("../src/features/chat/host/view"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); @@ -380,9 +380,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => { const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([connectedLeaf]); - threadSurfaces(plugin).notifyThreadArchived("thread-1"); + threadSurfaces(plugin).applyThreadArchived("thread-1"); - expect(refreshSharedThreadList).toHaveBeenCalledOnce(); + expect(refreshSharedThreadList).not.toHaveBeenCalled(); }); it("closes matching chat panels only when archive notification requests it", async () => { @@ -398,20 +398,20 @@ describe("CodexPanelPlugin boot restored panel loading", () => { vi.spyOn((otherLeaf.view as CodexChatView).surface, "refreshSharedThreadList").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([restoredMatchingLeaf, matchingLeaf, otherLeaf]); - threadSurfaces(plugin).notifyThreadArchived("thread-1"); + threadSurfaces(plugin).applyThreadArchived("thread-1"); expect(restoredMatchingLeaf.detach).not.toHaveBeenCalled(); expect(matchingLeaf.detach).not.toHaveBeenCalled(); expect(otherLeaf.detach).not.toHaveBeenCalled(); - threadSurfaces(plugin).notifyThreadArchived("thread-1", { closeOpenPanels: true }); + threadSurfaces(plugin).applyThreadArchived("thread-1", { closeOpenPanels: true }); expect(restoredMatchingLeaf.detach).toHaveBeenCalledOnce(); expect(matchingLeaf.detach).toHaveBeenCalledOnce(); expect(otherLeaf.detach).not.toHaveBeenCalled(); }); - it("refreshes shared thread lists after rename lifecycle notifications", async () => { + it("does not refresh shared thread lists after known rename mutations", async () => { const { CodexChatView } = await import("../src/features/chat/host/view"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); @@ -420,9 +420,9 @@ describe("CodexPanelPlugin boot restored panel loading", () => { const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([connectedLeaf]); - threadSurfaces(plugin).notifyThreadRenamed("thread-1", "Renamed thread"); + threadSurfaces(plugin).applyThreadRenamed("thread-1", "Renamed thread"); - expect(refreshSharedThreadList).toHaveBeenCalledOnce(); + expect(refreshSharedThreadList).not.toHaveBeenCalled(); }); it("single-flights shared thread list refreshes and caches successful results", async () => { @@ -502,7 +502,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { const plugin = await pluginWithLeaves([disconnectedLeaf, connectedLeaf]); - threadSurfaces(plugin).refreshSharedThreadListFromOpenSurface(); + threadSurfaces(plugin).invalidateThreadsFromOpenSurface(); expect(disconnectedRefresh).not.toHaveBeenCalled(); expect(connectedRefresh).toHaveBeenCalledOnce(); @@ -533,10 +533,10 @@ describe("CodexPanelPlugin boot restored panel loading", () => { const plugin = await pluginWithLeaves(leaves); sourceLeaf.detach(); - threadSurfaces(plugin).notifyThreadArchived("thread-1"); + threadSurfaces(plugin).applyThreadArchived("thread-1"); expect(sourceRefresh).not.toHaveBeenCalled(); - expect(remainingRefresh).toHaveBeenCalledOnce(); + expect(remainingRefresh).not.toHaveBeenCalled(); }); }); @@ -620,8 +620,8 @@ function chatHostFixture(): CodexChatHost { openTurnDiff: vi.fn(), }, threadCatalog: { - notifyThreadArchived: vi.fn(), - notifyThreadRenamed: vi.fn(), + archiveThreadInCatalog: vi.fn(), + renameThreadInCatalog: vi.fn(), refreshFromOpenSurface: vi.fn(), refreshThreadsViewLiveState: vi.fn(), applyThreads: vi.fn(), diff --git a/tests/workspace/shared-thread-catalog.test.ts b/tests/workspace/shared-thread-catalog.test.ts index f3ce88b1..530feb72 100644 --- a/tests/workspace/shared-thread-catalog.test.ts +++ b/tests/workspace/shared-thread-catalog.test.ts @@ -10,10 +10,10 @@ import type { ThreadSurfaceActions } from "../../src/workspace/thread-surface-ac type MockSurfaceActions = ThreadSurfaceActions & { applyThreadListSnapshot: Mock<(threads: readonly Thread[]) => void>; + applyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>; + applyThreadRenamed: Mock<(threadId: string, name: string | null) => 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", () => { @@ -55,14 +55,26 @@ describe("SharedThreadCatalog", () => { expect(surfaces.publishModels).toHaveBeenCalledWith(models); }); - it("forwards archive and rename notifications through the surface owner", () => { + it("applies known rename mutations to cache and surfaces", () => { const { catalog, surfaces } = catalogFixture(); + catalog.applyThreads([thread("thread"), thread("other")]); - catalog.notifyThreadArchived("thread", { closeOpenPanels: true }); - catalog.notifyThreadRenamed("thread", "Renamed"); + catalog.renameThreadInCatalog("thread", "Renamed"); - expect(surfaces.notifyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); - expect(surfaces.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed"); + expect(catalog.cachedThreads()).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]); + expect(surfaces.applyThreadListSnapshot).toHaveBeenLastCalledWith([{ ...thread("thread"), name: "Renamed" }, thread("other")]); + expect(surfaces.applyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed"); + }); + + it("applies known archive mutations to cache and surfaces", () => { + const { catalog, surfaces } = catalogFixture(); + catalog.applyThreads([thread("thread"), thread("other")]); + + catalog.archiveThreadInCatalog("thread", { closeOpenPanels: true }); + + expect(catalog.cachedThreads()).toEqual([thread("other")]); + expect(surfaces.applyThreadListSnapshot).toHaveBeenLastCalledWith([thread("other")]); + expect(surfaces.applyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); }); }); @@ -79,13 +91,13 @@ function catalogFixture() { function surfaceActions(): MockSurfaceActions { return { refreshOpenViews: vi.fn(), - refreshSharedThreadListFromOpenSurface: vi.fn(), + invalidateThreadsFromOpenSurface: vi.fn(), applyThreadListSnapshot: vi.fn(), + applyThreadArchived: vi.fn(), + applyThreadRenamed: vi.fn(), publishAppServerMetadata: vi.fn(), publishModels: vi.fn(), refreshThreadsViewLiveState: vi.fn(), - notifyThreadArchived: vi.fn(), - notifyThreadRenamed: vi.fn(), }; } diff --git a/tests/workspace/thread-surface-actions.test.ts b/tests/workspace/thread-surface-actions.test.ts index db86b6d5..a32c9734 100644 --- a/tests/workspace/thread-surface-actions.test.ts +++ b/tests/workspace/thread-surface-actions.test.ts @@ -29,7 +29,7 @@ describe("createThreadSurfaceActions", () => { } as never, }); - threadSurfaces.refreshSharedThreadListFromOpenSurface(); + threadSurfaces.invalidateThreadsFromOpenSurface(); expect(disconnectedPanelRefresh).not.toHaveBeenCalled(); expect(threadsRefresh).toHaveBeenCalledOnce(); @@ -65,12 +65,41 @@ describe("createThreadSurfaceActions", () => { } as never, }); - threadSurfaces.refreshSharedThreadListFromOpenSurface(); + threadSurfaces.invalidateThreadsFromOpenSurface(); expect(disconnectedPanelRefresh).not.toHaveBeenCalled(); expect(connectedPanelRefresh).toHaveBeenCalledOnce(); expect(threadsRefresh).not.toHaveBeenCalled(); }); + + it("applies known thread mutations without refreshing thread lists", () => { + const panel = { + surface: { + openPanelSnapshot: () => panelSnapshot({ connected: true }), + refreshSharedThreadList: vi.fn().mockResolvedValue(undefined), + applyThreadArchived: vi.fn(), + applyThreadRenamed: vi.fn(), + }, + }; + const threadSurfaces = createThreadSurfaceActions({ + app: { + workspace: { + getLeavesOfType: vi.fn(() => []), + }, + } as never, + panels: { + panelViews: () => [panel], + panelLeavesForThread: vi.fn(() => []), + } as never, + }); + + threadSurfaces.applyThreadRenamed("thread", "Renamed"); + threadSurfaces.applyThreadArchived("thread"); + + expect(panel.surface.applyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed"); + expect(panel.surface.applyThreadArchived).toHaveBeenCalledWith("thread"); + expect(panel.surface.refreshSharedThreadList).not.toHaveBeenCalled(); + }); }); function threadsView(overrides: Partial = {}): CodexThreadsView {