From 8f0db9fe35660454a3cf011a419c12e117af9bea Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 29 May 2026 05:59:12 +0900 Subject: [PATCH] Extract toolbar panel controller --- src/features/chat/toolbar-panel-controller.ts | 107 ++++++++++++++++++ src/features/chat/view.ts | 91 ++++----------- .../chat/toolbar-panel-controller.test.ts | 51 +++++++++ 3 files changed, 180 insertions(+), 69 deletions(-) create mode 100644 src/features/chat/toolbar-panel-controller.ts create mode 100644 tests/features/chat/toolbar-panel-controller.test.ts diff --git a/src/features/chat/toolbar-panel-controller.ts b/src/features/chat/toolbar-panel-controller.ts new file mode 100644 index 00000000..f5df2d98 --- /dev/null +++ b/src/features/chat/toolbar-panel-controller.ts @@ -0,0 +1,107 @@ +import type { ChatAction, ChatState, ChatStateStore } from "./chat-state"; +import type { ChatThreadActionController } from "./thread-actions"; +import type { ChatViewRenderScheduleOptions } from "./view-lifecycle"; + +export interface ToolbarPanelControllerHost { + stateStore: ChatStateStore; + threadActions: ChatThreadActionController; + scheduleRender: (options?: ChatViewRenderScheduleOptions) => void; +} + +export interface ToolbarOutsidePointerContext { + target: EventTarget | null; + viewWindow: ToolbarDomWindow | null; + contains: (element: Element) => boolean; + renameEditing: boolean; +} + +type ToolbarDomWindow = Window & { Element: typeof Element }; + +export class ToolbarPanelController { + private archiveConfirmThreadId: string | null = null; + + constructor(private readonly host: ToolbarPanelControllerHost) {} + + private get state(): ChatState { + return this.host.stateStore.getState(); + } + + private dispatch(action: ChatAction): void { + this.host.stateStore.dispatch(action); + } + + archiveConfirmId(): string | null { + return this.archiveConfirmThreadId; + } + + toggleHistory(): void { + this.dispatch({ type: "ui/panel-set", panel: "history", toggle: true }); + this.host.scheduleRender(); + } + + toggleStatus(): void { + this.dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true }); + this.host.scheduleRender(); + } + + toggleRuntime(picker: NonNullable): void { + this.dispatch({ type: "ui/panel-set", panel: picker, toggle: true }); + this.host.scheduleRender(); + } + + closeForThreadSelection(): void { + this.archiveConfirmThreadId = null; + } + + startArchive(threadId: string): void { + this.archiveConfirmThreadId = threadId; + this.host.scheduleRender({ forceSlots: true }); + } + + async archiveThread(threadId: string, saveMarkdown: boolean): Promise { + if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null; + await this.host.threadActions.archiveThread(threadId, saveMarkdown); + this.host.scheduleRender({ forceSlots: true }); + } + + closeOnOutsidePointer(context: ToolbarOutsidePointerContext): void { + if (!this.hasOpenPanel()) return; + + const target = context.target; + if (isToolbarElement(target, context.viewWindow)) { + const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel"); + if (insideToolbarPanel && context.contains(insideToolbarPanel)) { + if (this.archiveConfirmThreadId && !target.closest(".codex-panel__archive-confirm")) { + this.archiveConfirmThreadId = null; + this.host.scheduleRender({ forceSlots: true }); + } + return; + } + } + + if (this.archiveConfirmThreadId) { + this.archiveConfirmThreadId = null; + this.host.scheduleRender({ forceSlots: true }); + } + + if (context.renameEditing) return; + + this.close(); + } + + private hasOpenPanel(): boolean { + return this.state.openDetails.has("history") || this.state.openDetails.has("status-panel") || this.state.runtimePicker !== null; + } + + private close(): void { + if (!this.hasOpenPanel()) return; + + this.dispatch({ type: "ui/panel-set", panel: null }); + this.archiveConfirmThreadId = null; + this.host.scheduleRender({ forceSlots: true }); + } +} + +function isToolbarElement(target: EventTarget | null, viewWindow: ToolbarDomWindow | null): target is Element { + return Boolean(viewWindow && target instanceof viewWindow.Element); +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 6d8e8605..4b7208b2 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -84,6 +84,7 @@ import { } from "./turn-submission"; import { resumedThreadAction, type ResumedThreadActionParams } from "./thread-resume"; import { PendingRequestController } from "./pending-request-controller"; +import { ToolbarPanelController } from "./toolbar-panel-controller"; export interface CodexChatHost { readonly settings: CodexPanelSettings; @@ -113,13 +114,13 @@ export class CodexChatView extends ItemView { private readonly restoredThread: RestoredThreadController; private readonly threadRename: ThreadRenameController; private readonly pendingRequests: PendingRequestController; + private readonly toolbarPanels: ToolbarPanelController; private readonly chatState = createChatStateStore(); private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; private readonly deferredTasks: ChatViewDeferredTasks; private readonly composerController: ChatComposerController; private readonly messageRenderer: ChatMessageRenderer; private shellRenderVersion = 0; - private archiveConfirmThreadId: string | null = null; private readonly connectionWork = new ChatConnectionWorkTracker(); private readonly resumeWork: ChatResumeWorkTracker; private opened = false; @@ -296,6 +297,13 @@ export class CodexChatView extends ItemView { this.plugin.refreshSharedThreadListFromOpenSurface(); }, }); + this.toolbarPanels = new ToolbarPanelController({ + stateStore: this.chatState, + threadActions: this.threadActions, + scheduleRender: (options) => { + this.scheduleRender(options); + }, + }); this.runtimeSettings = new ChatRuntimeSettingsController({ stateStore: this.chatState, currentClient: () => this.client, @@ -924,11 +932,6 @@ export class CodexChatView extends ItemView { return latestProposedPlanItem(this.state.displayItems)?.id === item.id; } - private toggleRuntimePicker(picker: NonNullable): void { - this.dispatch({ type: "ui/panel-set", panel: picker, toggle: true }); - this.render(); - } - private async setRequestedModelFromUi(model: string | null): Promise { await this.runtimeSettings.setRequestedModelFromUi(model); } @@ -1087,16 +1090,16 @@ export class CodexChatView extends ItemView { renderToolbar(toolbar, model, { toggleHistory: () => { - this.toggleHistoryPanel(); + this.toolbarPanels.toggleHistory(); }, toggleAutoReview: () => void this.runtimeSettings.toggleAutoReview(), toggleStatusPanel: () => { - this.toggleStatusPanel(); + this.toolbarPanels.toggleStatus(); }, togglePlan: () => void this.runtimeSettings.toggleCollaborationMode(), toggleFast: () => void this.runtimeSettings.toggleFastMode(), toggleRuntime: () => { - this.toggleRuntimePicker("model"); + this.toolbarPanels.toggleRuntime("model"); }, connect: () => void this.reconnectFromToolbar(), refreshStatus: () => void this.refreshStatusPanel(), @@ -1106,9 +1109,9 @@ export class CodexChatView extends ItemView { void this.selectThread(threadId); }, startArchiveThread: (threadId) => { - this.startArchiveThread(threadId); + this.toolbarPanels.startArchive(threadId); }, - archiveThread: (threadId, saveMarkdown) => void this.archiveThread(threadId, saveMarkdown), + archiveThread: (threadId, saveMarkdown) => void this.toolbarPanels.archiveThread(threadId, saveMarkdown), startRenameThread: (threadId) => { this.threadRename.start(threadId); }, @@ -1131,7 +1134,7 @@ export class CodexChatView extends ItemView { turnBusy: this.turnBusy, vaultPath: this.plugin.vaultPath, configuredCommand: this.plugin.settings.codexPath, - archiveConfirmThreadId: this.archiveConfirmThreadId, + archiveConfirmThreadId: this.toolbarPanels.archiveConfirmId(), archiveExportEnabled: this.plugin.settings.archiveExportEnabled, ...runtimeToolbarChoices({ state: this.state, @@ -1164,68 +1167,23 @@ export class CodexChatView extends ItemView { } } - private toggleHistoryPanel(): void { - this.dispatch({ type: "ui/panel-set", panel: "history", toggle: true }); - this.scheduleRender(); - } - private async selectThread(threadId: string): Promise { if (this.turnBusy && threadId !== this.state.activeThreadId) { this.addSystemMessage("Finish or interrupt the current turn before switching threads."); return; } - this.archiveConfirmThreadId = null; + this.toolbarPanels.closeForThreadSelection(); if (await this.plugin.focusThreadInOpenView(threadId)) return; await this.resumeThread(threadId); } - private startArchiveThread(threadId: string): void { - this.archiveConfirmThreadId = threadId; - this.scheduleRender({ forceSlots: true }); - } - - private async archiveThread(threadId: string, saveMarkdown: boolean): Promise { - if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null; - await this.threadActions.archiveThread(threadId, saveMarkdown); - this.scheduleRender({ forceSlots: true }); - } - private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void { - if (!this.hasOpenToolbarPanel()) return; - - const target = event.target; - const viewWindow = this.containerEl.doc.defaultView; - if (viewWindow && target instanceof viewWindow.Element) { - const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel"); - if (insideToolbarPanel && this.containerEl.contains(insideToolbarPanel)) { - if (this.archiveConfirmThreadId && !target.closest(".codex-panel__archive-confirm")) { - this.archiveConfirmThreadId = null; - this.scheduleRender({ forceSlots: true }); - } - return; - } - } - - if (this.archiveConfirmThreadId) { - this.archiveConfirmThreadId = null; - this.scheduleRender({ forceSlots: true }); - } - - if (this.threadRename.isEditing()) return; - - this.closeToolbarPanel(); - } - - private hasOpenToolbarPanel(): boolean { - return this.state.openDetails.has("history") || this.state.openDetails.has("status-panel") || this.state.runtimePicker !== null; - } - - private closeToolbarPanel(): void { - if (!this.hasOpenToolbarPanel()) return; - - this.dispatch({ type: "ui/panel-set", panel: null }); - this.archiveConfirmThreadId = null; - this.scheduleRender({ forceSlots: true }); + this.toolbarPanels.closeOnOutsidePointer({ + target: event.target, + viewWindow: this.containerEl.doc.defaultView, + contains: (element) => this.containerEl.contains(element), + renameEditing: this.threadRename.isEditing(), + }); } private scheduleRender(options: ChatViewRenderScheduleOptions = {}): void { @@ -1254,11 +1212,6 @@ export class CodexChatView extends ItemView { return (this.containerEl.children[1] as HTMLElement | undefined) ?? null; } - private toggleStatusPanel(): void { - this.dispatch({ type: "ui/panel-set", panel: "status-panel", toggle: true }); - this.scheduleRender(); - } - private statusSummaryLines(): string[] { return buildStatusSummaryLines(this.state, this.runtimeSnapshot()); } diff --git a/tests/features/chat/toolbar-panel-controller.test.ts b/tests/features/chat/toolbar-panel-controller.test.ts new file mode 100644 index 00000000..c4386daa --- /dev/null +++ b/tests/features/chat/toolbar-panel-controller.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state"; +import { ToolbarPanelController } from "../../../src/features/chat/toolbar-panel-controller"; +import type { ChatThreadActionController } from "../../../src/features/chat/thread-actions"; + +describe("ToolbarPanelController", () => { + it("tracks archive confirmation and delegates archive actions", async () => { + const stateStore = createChatStateStore(createChatState()); + const archiveThread = vi.fn().mockResolvedValue(undefined); + const scheduleRender = vi.fn(); + const controller = new ToolbarPanelController({ + stateStore, + threadActions: { archiveThread } as unknown as ChatThreadActionController, + scheduleRender, + }); + + controller.startArchive("thread"); + expect(controller.archiveConfirmId()).toBe("thread"); + expect(scheduleRender).toHaveBeenCalledWith({ forceSlots: true }); + + await controller.archiveThread("thread", true); + + expect(archiveThread).toHaveBeenCalledWith("thread", true); + expect(controller.archiveConfirmId()).toBeNull(); + }); + + it("closes mutually exclusive toolbar panels on outside pointers", () => { + const stateStore = createChatStateStore(createChatState()); + const scheduleRender = vi.fn(); + const controller = new ToolbarPanelController({ + stateStore, + threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActionController, + scheduleRender, + }); + controller.toggleHistory(); + expect(stateStore.getState().openDetails.has("history")).toBe(true); + + controller.closeOnOutsidePointer({ + target: document.createElement("button"), + viewWindow: window, + contains: () => false, + renameEditing: false, + }); + + expect(stateStore.getState().openDetails.size).toBe(0); + expect(scheduleRender).toHaveBeenCalledWith({ forceSlots: true }); + }); +});