From d5fba854ca92ae2448d44a91bbc528c464b73c20 Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 29 May 2026 06:25:33 +0900 Subject: [PATCH] Extract thread identity controller --- .../chat/thread-identity-controller.ts | 61 ++++++++++++ src/features/chat/view.ts | 70 ++++++------- .../chat/thread-identity-controller.test.ts | 98 +++++++++++++++++++ 3 files changed, 189 insertions(+), 40 deletions(-) create mode 100644 src/features/chat/thread-identity-controller.ts create mode 100644 tests/features/chat/thread-identity-controller.test.ts diff --git a/src/features/chat/thread-identity-controller.ts b/src/features/chat/thread-identity-controller.ts new file mode 100644 index 00000000..a7efb8ca --- /dev/null +++ b/src/features/chat/thread-identity-controller.ts @@ -0,0 +1,61 @@ +import type { ChatStateStore } from "./chat-state"; +import type { RestoredThreadController } from "./restored-thread-controller"; + +export interface ThreadIdentityControllerHost { + stateStore: ChatStateStore; + restoredThread: RestoredThreadController; + invalidateResumeWork: () => void; + clearDeferredRestoredThreadHydration: () => void; + resetThreadTurnPresence: (hadTurns: boolean) => void; + notifyActiveThreadIdentityChanged: () => void; + refreshTabHeader: () => void; + refreshLiveState: () => void; + render: () => void; +} + +export class ThreadIdentityController { + constructor(private readonly host: ThreadIdentityControllerHost) {} + + clearActiveThreadContext(): void { + this.host.invalidateResumeWork(); + this.host.restoredThread.clear(); + this.host.clearDeferredRestoredThreadHydration(); + this.host.stateStore.dispatch({ type: "thread/active-cleared" }); + this.host.resetThreadTurnPresence(false); + this.host.notifyActiveThreadIdentityChanged(); + this.host.refreshLiveState(); + } + + notifyThreadArchived(threadId: string): void { + if (this.state.activeThreadId !== threadId) return; + this.clearActiveThreadContext(); + this.host.render(); + } + + notifyThreadRenamed(threadId: string, name: string | null): void { + let changed = false; + const listedThreads = this.state.listedThreads.map((thread) => { + if (thread.id !== threadId) return thread; + changed = true; + return { ...thread, name }; + }); + this.host.stateStore.dispatch({ type: "thread/list-applied", threads: listedThreads }); + const restoredThread = this.host.restoredThread.placeholder(); + if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) { + this.host.restoredThread.rename(threadId, name); + changed = true; + } + const activeThreadChanged = this.state.activeThreadId === threadId || this.host.restoredThread.isPending(threadId); + if (!changed && !activeThreadChanged) return; + if (activeThreadChanged) { + this.host.notifyActiveThreadIdentityChanged(); + } else { + this.host.refreshTabHeader(); + } + this.host.render(); + } + + private get state() { + return this.host.stateStore.getState(); + } +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 26e50932..213f7137 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -65,6 +65,7 @@ import { TurnSubmissionController } from "./turn-submission-controller"; import { SlashCommandController } from "./slash-command-controller"; import { ComposerSubmissionController } from "./composer-submission-controller"; import { ChatConnectionController } from "./connection-controller"; +import { ThreadIdentityController } from "./thread-identity-controller"; export interface CodexChatHost { readonly settings: CodexPanelSettings; @@ -93,6 +94,7 @@ export class CodexChatView extends ItemView { private readonly threadActions: ChatThreadActionController; private readonly runtimeSettings: ChatRuntimeSettingsController; private readonly restoredThread: RestoredThreadController; + private readonly threadIdentity: ThreadIdentityController; private readonly threadRename: ThreadRenameController; private readonly pendingRequests: PendingRequestController; private readonly toolbarPanels: ToolbarPanelController; @@ -458,6 +460,31 @@ export class CodexChatView extends ItemView { this.refreshTabHeader(); }, }); + this.threadIdentity = new ThreadIdentityController({ + stateStore: this.chatState, + restoredThread: this.restoredThread, + invalidateResumeWork: () => { + this.invalidateResumeWork(); + }, + clearDeferredRestoredThreadHydration: () => { + this.clearDeferredRestoredThreadHydration(); + }, + resetThreadTurnPresence: (hadTurns) => { + this.threadRename.resetThreadTurnPresence(hadTurns); + }, + notifyActiveThreadIdentityChanged: () => { + this.notifyActiveThreadIdentityChanged(); + }, + refreshTabHeader: () => { + this.refreshTabHeader(); + }, + refreshLiveState: () => { + this.plugin.refreshThreadsViewLiveState(); + }, + render: () => { + this.render(); + }, + }); this.threadRename = new ThreadRenameController({ stateStore: this.chatState, vaultPath: this.plugin.vaultPath, @@ -585,32 +612,11 @@ export class CodexChatView extends ItemView { } notifyThreadArchived(threadId: string): void { - if (this.clearArchivedActiveThread(threadId)) { - this.render(); - } + this.threadIdentity.notifyThreadArchived(threadId); } notifyThreadRenamed(threadId: string, name: string | null): void { - let changed = false; - const listedThreads = this.state.listedThreads.map((thread) => { - if (thread.id !== threadId) return thread; - changed = true; - return { ...thread, name }; - }); - this.dispatch({ type: "thread/list-applied", threads: listedThreads }); - const restoredThread = this.restoredThreadPlaceholder(); - if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) { - this.restoredThread.rename(threadId, name); - changed = true; - } - const activeThreadChanged = this.state.activeThreadId === threadId || this.isRestoredThreadPending(threadId); - if (!changed && !activeThreadChanged) return; - if (activeThreadChanged) { - this.notifyActiveThreadIdentityChanged(); - } else { - this.refreshTabHeader(); - } - this.render(); + this.threadIdentity.notifyThreadRenamed(threadId, name); } override async onOpen(): Promise { @@ -672,7 +678,7 @@ export class CodexChatView extends ItemView { async startNewThread(): Promise { if (this.turnBusy) return; - this.clearActiveThreadContext(); + this.threadIdentity.clearActiveThreadContext(); this.chatState.dispatch({ type: "ui/panel-set", panel: null }); this.setStatus("New chat."); this.messageScroll.forceBottom(); @@ -1095,22 +1101,6 @@ export class CodexChatView extends ItemView { return runtimeSnapshotForChatState({ state }); } - private clearActiveThreadContext(): void { - this.invalidateResumeWork(); - this.clearRestoredThreadLifecycle(); - this.clearDeferredRestoredThreadHydration(); - this.chatState.dispatch({ type: "thread/active-cleared" }); - this.threadRename.resetThreadTurnPresence(false); - this.notifyActiveThreadIdentityChanged(); - this.plugin.refreshThreadsViewLiveState(); - } - - private clearArchivedActiveThread(threadId: string): boolean { - if (this.state.activeThreadId !== threadId) return false; - this.clearActiveThreadContext(); - return true; - } - private renderMessages(parent: HTMLElement): void { this.messageRenderer.render(parent); } diff --git a/tests/features/chat/thread-identity-controller.test.ts b/tests/features/chat/thread-identity-controller.test.ts new file mode 100644 index 00000000..2bdf76e6 --- /dev/null +++ b/tests/features/chat/thread-identity-controller.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state"; +import { ThreadIdentityController } from "../../../src/features/chat/thread-identity-controller"; +import type { RestoredThreadController } from "../../../src/features/chat/restored-thread-controller"; +import type { RestoredThreadPlaceholderState } from "../../../src/features/chat/view-lifecycle"; +import type { Thread } from "../../../src/generated/app-server/v2/Thread"; + +function thread(id: string, name: string | null = null): Thread { + return { + id, + sessionId: id, + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 0, + updatedAt: 0, + status: { type: "idle" }, + path: null, + cwd: "/vault", + cliVersion: "test", + source: "appServer", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name, + turns: [], + }; +} + +function createController() { + const stateStore = createChatStateStore(createChatState()); + const restoredPlaceholder = vi.fn<() => RestoredThreadPlaceholderState | null>(() => null); + const restoredRename = vi.fn(); + const restoredThread = { + clear: vi.fn(), + isPending: vi.fn(() => false), + placeholder: restoredPlaceholder, + rename: restoredRename, + } as unknown as RestoredThreadController; + const host = { + stateStore, + restoredThread, + invalidateResumeWork: vi.fn(), + clearDeferredRestoredThreadHydration: vi.fn(), + resetThreadTurnPresence: vi.fn(), + notifyActiveThreadIdentityChanged: vi.fn(), + refreshTabHeader: vi.fn(), + refreshLiveState: vi.fn(), + render: vi.fn(), + }; + return { controller: new ThreadIdentityController(host), host, restoredPlaceholder, restoredRename, stateStore }; +} + +describe("ThreadIdentityController", () => { + it("clears the active thread when it is archived", () => { + const { controller, host, stateStore } = createController(); + stateStore.dispatch({ + type: "thread/resumed", + thread: thread("thread"), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalPolicy: null, + approvalsReviewer: null, + activePermissionProfile: null, + }); + + controller.notifyThreadArchived("thread"); + + expect(stateStore.getState().activeThreadId).toBeNull(); + expect(host.invalidateResumeWork).toHaveBeenCalledOnce(); + expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); + expect(host.notifyActiveThreadIdentityChanged).toHaveBeenCalledOnce(); + expect(host.render).toHaveBeenCalledOnce(); + }); + + it("updates listed and restored thread titles on rename notifications", () => { + const { controller, host, restoredPlaceholder, restoredRename, stateStore } = createController(); + stateStore.dispatch({ type: "thread/list-applied", threads: [thread("thread", "Old")] }); + restoredPlaceholder.mockReturnValue({ + kind: "placeholder", + threadId: "thread", + title: "Old", + explicitName: "Old", + loading: null, + }); + + controller.notifyThreadRenamed("thread", "New"); + + expect(stateStore.getState().listedThreads[0]?.name).toBe("New"); + expect(restoredRename).toHaveBeenCalledWith("thread", "New"); + expect(host.render).toHaveBeenCalledOnce(); + }); +});