Extract toolbar panel controller

This commit is contained in:
murashit 2026-05-29 05:59:12 +09:00
parent 8d5f89636c
commit 8f0db9fe35
3 changed files with 180 additions and 69 deletions

View file

@ -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<ChatState["runtimePicker"]>): 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<void> {
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);
}

View file

@ -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<ChatState["runtimePicker"]>): void {
this.dispatch({ type: "ui/panel-set", panel: picker, toggle: true });
this.render();
}
private async setRequestedModelFromUi(model: string | null): Promise<void> {
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<void> {
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<void> {
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());
}

View file

@ -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 });
});
});