diff --git a/src/features/chat/chat-host.ts b/src/features/chat/chat-host.ts index c65c3225..9d545c7a 100644 --- a/src/features/chat/chat-host.ts +++ b/src/features/chat/chat-host.ts @@ -13,6 +13,7 @@ export interface CodexChatHost { notifyThreadArchived(threadId: string): void; notifyThreadRenamed(threadId: string, name: string | null): void; refreshThreadsViewLiveState(): void; + refreshOpenViews(): void; refreshSharedThreadListFromOpenSurface(): void; applyThreadListSnapshot(threads: readonly Thread[]): void; refreshThreadList(fetchThreads: () => Promise): Promise; diff --git a/src/features/chat/chat-view-controller-assembly.ts b/src/features/chat/chat-view-controller-assembly.ts index 1d71ff9d..8ca66b24 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -154,6 +154,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl renderController = new ChatViewRenderController({ shell: createChatShellRenderPort(host.stateStore, { connected: () => connection.isConnected(), + showToolbar: () => host.plugin.settings.showToolbar, pendingRequestsSignature: host.pendingRequestsSignature, activeComposerThreadName: host.activeComposerThreadName, }), diff --git a/src/features/chat/controllers/state-ports.ts b/src/features/chat/controllers/state-ports.ts index 50796af4..3bc943f8 100644 --- a/src/features/chat/controllers/state-ports.ts +++ b/src/features/chat/controllers/state-ports.ts @@ -189,6 +189,7 @@ export function createChatShellRenderPort( stateStore: ChatStateStore, options: { connected: () => boolean; + showToolbar: () => boolean; pendingRequestsSignature: () => string; activeComposerThreadName: () => string | null; }, @@ -198,6 +199,7 @@ export function createChatShellRenderPort( renderChatPanelShell(root, { stateStore, renderVersion, + showToolbar: options.showToolbar(), toolbar: { render: slots.renderToolbar, snapshot: (state) => toolbarSlotSnapshot(state, options.connected()) }, messages: { render: slots.renderMessages, diff --git a/src/features/chat/ui/shell.tsx b/src/features/chat/ui/shell.tsx index 820ef1af..1ba3c9a7 100644 --- a/src/features/chat/ui/shell.tsx +++ b/src/features/chat/ui/shell.tsx @@ -5,6 +5,7 @@ import type { ChatPanelSlotSnapshot } from "../view-snapshot"; export interface ChatPanelShellProps { stateStore: ChatStateStore; renderVersion: number; + showToolbar: boolean; toolbar: ChatPanelSlotProps; messages: ChatPanelSlotProps; composer: ChatPanelSlotProps; @@ -22,7 +23,10 @@ const shellSlots = { toolbar: { selector: ":scope > .codex-panel__toolbar", create(container: HTMLElement): HTMLElement { - return container.createDiv({ cls: "codex-panel__toolbar" }); + const toolbar = container.createDiv({ cls: "codex-panel__toolbar" }); + const body = container.querySelector(":scope > .codex-panel__body"); + if (body) container.insertBefore(toolbar, body); + return toolbar; }, props(props: ChatPanelShellProps): ChatPanelSlotProps { return props.toolbar; @@ -54,7 +58,7 @@ const shellSlotDefinitions = Object.values(shellSlots); export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShellProps): void { container.addClass("codex-panel"); - ensureShellDom(container); + ensureShellDom(container, props.showToolbar); const existing = shellMounts.get(container); if (existing?.stateStore === props.stateStore) { existing.props = props; @@ -86,17 +90,23 @@ interface ChatPanelSlotProps { snapshot: (state: ChatState) => ChatPanelSlotSnapshot; } -function ensureShellDom(container: HTMLElement): void { - if (shellSlotDefinitions.every((slot) => container.querySelector(slot.selector))) { +function ensureShellDom(container: HTMLElement, showToolbar: boolean): void { + if (!showToolbar) { + const toolbar = container.querySelector(shellSlots.toolbar.selector); + unmountUiRoot(toolbar); + toolbar?.remove(); + } + const requiredSlots = activeShellSlotDefinitions(showToolbar); + if (requiredSlots.every((slot) => container.querySelector(slot.selector))) { return; } // The shell owns the fixed Obsidian DOM scaffold; toolbar, messages, and // composer each own their own Preact root inside that scaffold. unmountSlotRoots(container); container.replaceChildren(); - shellSlots.toolbar.create(container); - shellSlots.messages.create(container); - shellSlots.composer.create(container); + for (const slot of requiredSlots) { + slot.create(container); + } } function renderSlotIfNeeded(element: HTMLElement, slot: ChatPanelSlotProps, renderKey: string): void { @@ -107,7 +117,7 @@ function renderSlotIfNeeded(element: HTMLElement, slot: ChatPanelSlotProps, rend function renderMountedSlots(container: HTMLElement, props: ChatPanelShellProps): void { const state = props.stateStore.getState(); - for (const slotDefinition of shellSlotDefinitions) { + for (const slotDefinition of activeShellSlotDefinitions(props.showToolbar)) { const element = container.querySelector(slotDefinition.selector); if (!element) continue; const slot = slotDefinition.props(props); @@ -115,6 +125,10 @@ function renderMountedSlots(container: HTMLElement, props: ChatPanelShellProps): } } +function activeShellSlotDefinitions(showToolbar: boolean): typeof shellSlotDefinitions { + return showToolbar ? shellSlotDefinitions : shellSlotDefinitions.filter((slot) => slot !== shellSlots.toolbar); +} + function unmountSlotRoots(container: HTMLElement): void { for (const slotDefinition of shellSlotDefinitions) { unmountUiRoot(container.querySelector(slotDefinition.selector)); diff --git a/src/settings/model.ts b/src/settings/model.ts index dbdb8fcc..515da1a6 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -11,6 +11,7 @@ export interface CodexPanelSettings { threadNamingEffort: ReasoningEffort | null; rewriteSelectionModel: string | null; rewriteSelectionEffort: ReasoningEffort | null; + showToolbar: boolean; sendShortcut: SendShortcut; scrollThreadFromComposerEdges: boolean; archiveExportEnabled: boolean; @@ -25,6 +26,7 @@ export const DEFAULT_SETTINGS: CodexPanelSettings = { threadNamingEffort: null, rewriteSelectionModel: null, rewriteSelectionEffort: null, + showToolbar: true, sendShortcut: "enter", scrollThreadFromComposerEdges: false, archiveExportEnabled: false, @@ -41,6 +43,7 @@ export function normalizeSettings(data: unknown): CodexPanelSettings { threadNamingEffort: reasoningEffortOrDefault(record["threadNamingEffort"]), rewriteSelectionModel: modelOrDefault(record["rewriteSelectionModel"]), rewriteSelectionEffort: reasoningEffortOrDefault(record["rewriteSelectionEffort"]), + showToolbar: booleanOrDefault(record["showToolbar"], DEFAULT_SETTINGS.showToolbar), sendShortcut: sendShortcutOrDefault(record["sendShortcut"]), scrollThreadFromComposerEdges: booleanOrDefault( record["scrollThreadFromComposerEdges"], @@ -61,12 +64,13 @@ export function normalizeSettings(data: unknown): CodexPanelSettings { export function settingsMatchNormalizedData(data: unknown, settings: CodexPanelSettings): boolean { const record = asRecord(data); return ( - Object.keys(record).length === 11 && + Object.keys(record).length === 12 && record["codexPath"] === settings.codexPath && record["threadNamingModel"] === settings.threadNamingModel && record["threadNamingEffort"] === settings.threadNamingEffort && record["rewriteSelectionModel"] === settings.rewriteSelectionModel && record["rewriteSelectionEffort"] === settings.rewriteSelectionEffort && + record["showToolbar"] === settings.showToolbar && record["sendShortcut"] === settings.sendShortcut && record["scrollThreadFromComposerEdges"] === settings.scrollThreadFromComposerEdges && record["archiveExportEnabled"] === settings.archiveExportEnabled && diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 873eabaf..bceac461 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -81,6 +81,16 @@ export class CodexPanelSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }); }); + new Setting(configSection) + .setName("Show chat toolbar") + .setDesc("Show the chat panel toolbar. Slash commands, composer status controls, and the threads view remain available when hidden.") + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.showToolbar).onChange(async (value) => { + this.plugin.settings.showToolbar = value; + await this.plugin.saveSettings(); + this.plugin.refreshOpenViews(); + }); + }); const composerSection = containerEl.createDiv({ cls: "codex-panel-settings__section codex-panel-settings__composer-section" }); renderSettingsHeading(composerSection, "Composer"); @@ -522,6 +532,7 @@ export interface CodexPanelSettingTabHost extends Plugin { settings: CodexPanelSettings; vaultPath: string; saveSettings(): Promise; + refreshOpenViews(): void; refreshSharedThreadListFromOpenSurface(): void; cachedModels(): Model[]; publishModels(models: Model[]): void; diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx index 01d8d229..fe313b60 100644 --- a/tests/features/chat/ui/shell.test.tsx +++ b/tests/features/chat/ui/shell.test.tsx @@ -86,6 +86,34 @@ describe("ChatPanelShell", () => { }); }); + it("removes and restores the toolbar slot from shell props", async () => { + const store = createChatStateStore(); + const container = document.createElement("div"); + document.body.appendChild(container); + const renderers = shellRenderers(store); + + await act(async () => { + renderChatPanelShell(container, { ...renderers, showToolbar: false }); + await settleShellEffects(); + }); + + expect(container.querySelector(".codex-panel__toolbar")).toBeNull(); + expect(container.querySelector(".codex-panel__slot--messages")).not.toBeNull(); + expect(container.querySelector(".codex-panel__slot--composer")).not.toBeNull(); + + await act(async () => { + renderChatPanelShell(container, { ...renderers, showToolbar: true }); + await settleShellEffects(); + }); + + expect(container.querySelector(".codex-panel__toolbar")).not.toBeNull(); + expect(container.firstElementChild?.classList.contains("codex-panel__toolbar")).toBe(true); + + await act(async () => { + unmountChatPanelShell(container); + }); + }); + it("unmounts existing slot roots before rebuilding a damaged shell scaffold", async () => { const store = createChatStateStore(); const container = document.createElement("div"); @@ -163,6 +191,7 @@ function shellRenderers(store: ReturnType) { return { stateStore: store, renderVersion: 0, + showToolbar: true, toolbar: { render: vi.fn((toolbar: HTMLElement) => { toolbar.textContent = store.getState().status; @@ -188,6 +217,7 @@ function nestedRootShellRenderers(store: ReturnType return { stateStore: store, renderVersion: 0, + showToolbar: true, toolbar: { render: vi.fn((toolbar: HTMLElement) => { renderUiRoot( @@ -225,6 +255,7 @@ function trackedRootShellRenderers(store: ReturnType { renderUiRoot(toolbar, ); diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 9dbeeb29..acb35e64 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -1050,6 +1050,7 @@ function chatHost(overrides: Partial = {}): CodexChatHost { openTurnDiff: vi.fn(), notifyThreadArchived: vi.fn(), notifyThreadRenamed: vi.fn(), + refreshOpenViews: vi.fn(), refreshSharedThreadListFromOpenSurface: vi.fn(), refreshThreadsViewLiveState: vi.fn(), applyThreadListSnapshot: vi.fn(), diff --git a/tests/main.test.ts b/tests/main.test.ts index 0b694e05..82e4441a 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -540,6 +540,7 @@ function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf) { openTurnDiff: vi.fn(), notifyThreadArchived: vi.fn(), notifyThreadRenamed: vi.fn(), + refreshOpenViews: vi.fn(), refreshSharedThreadListFromOpenSurface: vi.fn(), refreshThreadsViewLiveState: vi.fn(), applyThreadListSnapshot: vi.fn(), diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 14f9a47f..2e615e7c 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -85,6 +85,7 @@ describe("settings tab", () => { expect(tab.containerEl.querySelector("h2")).toBeNull(); expect(settingNames(tab)).toEqual([ "Codex executable", + "Show chat toolbar", "Composer", "Send shortcut", "Scroll thread from composer edges", @@ -113,6 +114,24 @@ describe("settings tab", () => { expect(tab.containerEl.querySelector(".codex-panel-settings__section-status")?.textContent ?? "").not.toContain("Obsidian hotkeys"); }); + it("saves the toolbar visibility setting and refreshes open panels", async () => { + const saveSettings = vi.fn().mockResolvedValue(undefined); + const refreshOpenViews = vi.fn(); + const tab = newSettingsTab({ saveSettings, refreshOpenViews }); + + tab.display(); + const toggle = inputForSetting(tab, "Show chat toolbar"); + if (!toggle) throw new Error("Missing toolbar visibility toggle"); + + toggle.checked = false; + toggle.dispatchEvent(new Event("change")); + await flushPromises(); + + expect(saveSettings).toHaveBeenCalledOnce(); + expect(refreshOpenViews).toHaveBeenCalledOnce(); + expect(settingDesc(tab, "Show chat toolbar")).toContain("Slash commands"); + }); + it("saves the composer edge scroll setting", async () => { const saveSettings = vi.fn().mockResolvedValue(undefined); const tab = newSettingsTab({ saveSettings }); @@ -345,6 +364,7 @@ function newSettingsTab( sendShortcut?: "enter" | "mod-enter"; cachedModels?: Model[]; publishModels?: (models: Model[]) => void; + refreshOpenViews?: () => void; } = {}, ): CodexPanelSettingTab { return new CodexPanelSettingTab( @@ -356,6 +376,7 @@ function newSettingsTab( threadNamingEffort: null, rewriteSelectionModel: null, rewriteSelectionEffort: null, + showToolbar: true, sendShortcut: options.sendShortcut ?? "enter", scrollThreadFromComposerEdges: false, archiveExportEnabled: false, @@ -365,6 +386,7 @@ function newSettingsTab( }, vaultPath: "/vault", saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined), + refreshOpenViews: options.refreshOpenViews ?? vi.fn(), refreshSharedThreadListFromOpenSurface: vi.fn(), cachedModels: vi.fn(() => options.cachedModels ?? []), publishModels: options.publishModels ?? vi.fn(), diff --git a/tests/settings/settings.test.ts b/tests/settings/settings.test.ts index 697b548a..6634e3dc 100644 --- a/tests/settings/settings.test.ts +++ b/tests/settings/settings.test.ts @@ -21,6 +21,7 @@ describe("settings", () => { threadNamingEffort: "low", rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal", + showToolbar: false, sendShortcut: "mod-enter", scrollThreadFromComposerEdges: true, archiveExportEnabled: true, @@ -39,6 +40,7 @@ describe("settings", () => { threadNamingEffort: "low", rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal", + showToolbar: false, sendShortcut: "mod-enter", scrollThreadFromComposerEdges: true, archiveExportEnabled: true, @@ -62,6 +64,7 @@ describe("settings", () => { threadNamingEffort: "low", rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal", + showToolbar: true, sendShortcut: "mod-enter", scrollThreadFromComposerEdges: true, archiveExportEnabled: true, @@ -101,6 +104,12 @@ describe("settings", () => { expect(normalizeSettings({ sendShortcut: "invalid" }).sendShortcut).toBe(DEFAULT_SETTINGS.sendShortcut); }); + it("shows the chat toolbar by default", () => { + expect(normalizeSettings({}).showToolbar).toBe(true); + expect(normalizeSettings({ showToolbar: false }).showToolbar).toBe(false); + expect(normalizeSettings({ showToolbar: "no" }).showToolbar).toBe(DEFAULT_SETTINGS.showToolbar); + }); + it("normalizes composer edge scrolling", () => { expect(normalizeSettings({ scrollThreadFromComposerEdges: true }).scrollThreadFromComposerEdges).toBe(true); expect(normalizeSettings({ scrollThreadFromComposerEdges: "yes" }).scrollThreadFromComposerEdges).toBe(