mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add chat toolbar visibility setting
This commit is contained in:
parent
127d72b77a
commit
e0b3c29c2e
11 changed files with 106 additions and 9 deletions
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue