Add chat toolbar visibility setting

This commit is contained in:
murashit 2026-06-05 13:43:00 +09:00
parent 127d72b77a
commit e0b3c29c2e
11 changed files with 106 additions and 9 deletions

View file

@ -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<readonly Thread[]>): Promise<readonly Thread[]>;

View file

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

View file

@ -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,

View file

@ -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<HTMLElement>(":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<HTMLElement>(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<HTMLElement>(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<HTMLElement>(slotDefinition.selector));

View file

@ -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 &&

View file

@ -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<void>;
refreshOpenViews(): void;
refreshSharedThreadListFromOpenSurface(): void;
cachedModels(): Model[];
publishModels(models: Model[]): void;

View file

@ -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<typeof createChatStateStore>) {
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<typeof createChatStateStore>
return {
stateStore: store,
renderVersion: 0,
showToolbar: true,
toolbar: {
render: vi.fn((toolbar: HTMLElement) => {
renderUiRoot(
@ -225,6 +255,7 @@ function trackedRootShellRenderers(store: ReturnType<typeof createChatStateStore
return {
stateStore: store,
renderVersion: 0,
showToolbar: true,
toolbar: {
render: vi.fn((toolbar: HTMLElement) => {
renderUiRoot(toolbar, <TrackedSlot slot="toolbar" cleanup={cleanup} />);

View file

@ -1050,6 +1050,7 @@ function chatHost(overrides: Partial<CodexChatHost> = {}): CodexChatHost {
openTurnDiff: vi.fn(),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshOpenViews: vi.fn(),
refreshSharedThreadListFromOpenSurface: vi.fn(),
refreshThreadsViewLiveState: vi.fn(),
applyThreadListSnapshot: vi.fn(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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(