diff --git a/eslint.config.mjs b/eslint.config.mjs index 012305bf..4165226e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -194,12 +194,14 @@ const uiRootBridgeFiles = [ "src/features/chat/ui/turn-diff/view.ts", "src/features/selection-rewrite/popover.tsx", "src/features/threads-view/renderer.tsx", + "src/settings/render.tsx", ]; const nonChatImperativeDomBridgeFiles = [ "src/features/selection-rewrite/popover.tsx", "src/features/thread-picker/modal.ts", "src/features/threads-view/renderer.tsx", - "src/settings/dynamic-sections.ts", + "src/settings/dynamic-sections.tsx", + "src/settings/render.tsx", "src/settings/tab.ts", "src/shared/diff/render.ts", "src/shared/ui/components.tsx", diff --git a/src/settings/dynamic-sections.ts b/src/settings/dynamic-sections.ts deleted file mode 100644 index eb1fe084..00000000 --- a/src/settings/dynamic-sections.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { Setting } from "obsidian"; - -import type { HookItem } from "../domain/catalog/metadata"; -import type { Thread } from "../domain/threads/model"; -import { shortThreadId } from "../utils"; -import { archivedThreadDisplayTitle } from "./archived-thread-title"; - -export interface ArchivedThreadSectionState { - exportEnabled: boolean; - exportFolderTemplate: string; - exportFilenameTemplate: string; - exportTags: string; - threads: readonly Thread[]; - loaded: boolean; - loading: boolean; - status: string; - deleteConfirmThreadId: string | null; - onExportEnabledChange(enabled: boolean): void; - onExportFolderTemplateChange(value: string): void; - onExportFilenameTemplateChange(value: string): void; - onExportTagsChange(value: string): void; - onRestore(threadId: string): void; - onStartDelete(threadId: string): void; - onDelete(threadId: string): void; -} - -export interface HookSectionState { - hooks: readonly HookItem[]; - warnings: readonly string[]; - errors: readonly string[]; - loaded: boolean; - loading: boolean; - status: string; - onTrust(hook: HookItem): void; - onToggleEnabled(hook: HookItem, enabled: boolean): void; -} - -export function renderHookSection(containerEl: HTMLElement, state: HookSectionState): void { - const section = containerEl.createDiv({ cls: "codex-panel-settings__dynamic-section codex-panel-settings__hook-section" }); - new Setting(section) - .setClass("codex-panel-settings__dynamic-section-heading") - .setHeading() - .setName("Hook status") - .setDesc("Review discovered hooks, trust changes, and turn hooks on or off."); - - if (state.loaded) { - renderHooks(section, state); - } else if (!state.loading && state.status) { - section.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: state.status }); - } -} - -export function renderArchivedThreadSection(containerEl: HTMLElement, state: ArchivedThreadSectionState): void { - const section = containerEl.createDiv({ - cls: "codex-panel-settings__dynamic-section codex-panel-settings__archived-section", - }); - new Setting(section) - .setClass("codex-panel-settings__dynamic-section-heading") - .setHeading() - .setName("Thread archiving") - .setDesc( - "Choose the default archive behavior and configure saved thread notes. Thread lists offer both archive choices; slash commands use the default.", - ); - - renderArchiveExportSettings(section, state); - - if (state.loaded && state.threads.length === 0) { - section.createEl("p", { - cls: "setting-item-description codex-panel-settings__dynamic-section-status", - text: "No archived threads.", - }); - } else if (state.loaded) { - renderArchivedThreadList(section, state); - } else if (!state.loading && state.status) { - section.createEl("p", { - cls: "setting-item-description codex-panel-settings__dynamic-section-status", - text: state.status, - }); - } -} - -function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedThreadSectionState): void { - new Setting(containerEl) - .setName("Save note by default") - .setDesc( - "When on, the default archive action saves a markdown note before archiving. When off, the default archives without saving. If saving fails, the thread stays active. Frontmatter includes title, thread_id, created, and optional tags.", - ) - .addToggle((toggle) => { - toggle.setValue(state.exportEnabled).onChange((value) => { - state.onExportEnabledChange(value); - }); - }); - - new Setting(containerEl) - .setName("Saved note folder") - .setDesc("Vault-relative folder for saved thread notes. The folder is created when needed.") - .addText((text) => { - text - .setPlaceholder("Codex archives") - .setValue(state.exportFolderTemplate) - .onChange((value) => { - state.onExportFolderTemplateChange(value); - }); - }); - - new Setting(containerEl) - .setName("Saved note filename") - .setDesc("Filename template. Variables: {{date}}, {{time}}, {{title}}, {{id}}, {{shortId}}. Existing files get a numeric suffix.") - .addText((text) => { - text - .setPlaceholder("{{date}} {{time}} {{title}} {{shortId}}.md") - .setValue(state.exportFilenameTemplate) - .onChange((value) => { - state.onExportFilenameTemplateChange(value); - }); - }); - - new Setting(containerEl) - .setName("Saved note tags") - .setDesc("Comma-separated fixed tags for saved notes. Leave empty to omit tags.") - .addText((text) => { - text - .setPlaceholder("Codex, archive") - .setValue(state.exportTags) - .onChange((value) => { - state.onExportTagsChange(value); - }); - }); -} - -function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThreadSectionState): void { - containerEl.createEl("p", { - cls: "setting-item-description codex-panel-settings__dynamic-list-summary", - text: "Restore archived threads, or permanently delete archived threads you no longer need.", - }); - const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" }); - for (const thread of state.threads) { - const title = archivedThreadDisplayTitle(thread); - const deleteConfirming = state.deleteConfirmThreadId === thread.id; - const setting = new Setting(list) - .setClass("codex-panel-settings__dynamic-row") - .setName(title) - .setDesc( - deleteConfirming - ? "Permanently delete this archived thread? This cannot be undone." - : `Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`, - ); - if (!deleteConfirming) { - setting.addExtraButton((button) => { - button.setIcon("rotate-ccw").onClick(() => { - state.onRestore(thread.id); - }); - button.extraSettingsEl.addClass("codex-panel-settings__archived-restore"); - button.extraSettingsEl.setAttr("aria-label", `Restore ${title}`); - }); - } - setting.addExtraButton((button) => { - button.setIcon(deleteConfirming ? "check" : "shredder").onClick(() => { - if (deleteConfirming) { - state.onDelete(thread.id); - } else { - state.onStartDelete(thread.id); - } - }); - button.extraSettingsEl.addClass( - deleteConfirming ? "codex-panel-settings__archived-delete-confirm" : "codex-panel-settings__archived-delete", - ); - button.extraSettingsEl.setAttr("aria-label", deleteConfirming ? `Confirm permanent delete ${title}` : `Delete ${title}`); - }); - setting.settingEl.addClass("codex-panel-settings__archived-row"); - if (deleteConfirming) setting.settingEl.addClass("codex-panel-settings__archived-row--delete-confirming"); - } -} - -function renderHooks(containerEl: HTMLElement, state: HookSectionState): void { - if (state.hooks.length === 0) { - containerEl.createEl("p", { cls: "setting-item-description", text: "No hooks found for this vault root." }); - } else { - const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list" }); - for (const hook of state.hooks) { - renderHookRow(list, hook, state); - } - } - - for (const warning of state.warnings) { - containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-warning", text: warning }); - } - for (const error of state.errors) { - containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-error", text: error }); - } -} - -function renderHookRow(list: HTMLElement, hook: HookItem, state: HookSectionState): void { - const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified"); - const hookName = firstNonEmptyString(hook.statusMessage, hook.command, hook.matcher, hook.eventName); - const setting = new Setting(list) - .setClass("codex-panel-settings__dynamic-row") - .setName(hookName) - .setDesc(`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`) - .addButton((button) => { - button - .setButtonText("Trust") - .setDisabled(state.loading || !canTrust) - .onClick(() => { - state.onTrust(hook); - }); - }) - .addButton((button) => { - button - .setButtonText(hook.enabled ? "Disable" : "Enable") - .setDisabled(state.loading || hook.isManaged) - .onClick(() => { - state.onToggleEnabled(hook, !hook.enabled); - }); - }); - setting.settingEl.addClass("codex-panel-settings__hook-row"); - setting.descEl.createDiv({ - cls: "codex-panel-settings__hook-hash", - text: hook.currentHash, - }); -} - -function firstNonEmptyString(...values: (string | null | undefined)[]): string { - return ( - values.find((value): value is string => typeof value === "string" && value.length > 0) ?? - values.find((value): value is string => typeof value === "string") ?? - "" - ); -} - -function formatThreadDate(timestamp: number): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown"; - return new Date(timestamp * 1000).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} diff --git a/src/settings/dynamic-sections.tsx b/src/settings/dynamic-sections.tsx new file mode 100644 index 00000000..3202d448 --- /dev/null +++ b/src/settings/dynamic-sections.tsx @@ -0,0 +1,512 @@ +import type { ComponentChild as UiNode, TargetedMouseEvent, TargetedPointerEvent } from "preact"; + +import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata"; +import type { Thread } from "../domain/threads/model"; +import { IconButton } from "../shared/ui/components"; +import { shortThreadId } from "../utils"; +import { archivedThreadDisplayTitle } from "./archived-thread-title"; + +const CODEX_DEFAULT_VALUE = "__codex-default__"; + +interface HelperSettingsState { + threadNamingModel: string | null; + threadNamingEffort: ReasoningEffort | null; + rewriteSelectionModel: string | null; + rewriteSelectionEffort: ReasoningEffort | null; + models: readonly ModelMetadata[]; + modelLoadFailed: boolean; + modelStatus: string; + onThreadNamingModelChange: (value: string | null) => void; + onThreadNamingEffortChange: (value: ReasoningEffort | null) => void; + onRewriteSelectionModelChange: (value: string | null) => void; + onRewriteSelectionEffortChange: (value: ReasoningEffort | null) => void; +} + +interface ArchivedThreadSectionState { + exportEnabled: boolean; + exportFolderTemplate: string; + exportFilenameTemplate: string; + exportTags: string; + threads: readonly Thread[]; + loaded: boolean; + loading: boolean; + status: string; + deleteConfirmThreadId: string | null; + onExportEnabledChange: (enabled: boolean) => void; + onExportFolderTemplateChange: (value: string) => void; + onExportFilenameTemplateChange: (value: string) => void; + onExportTagsChange: (value: string) => void; + onRestore: (threadId: string) => void; + onStartDelete: (threadId: string) => void; + onDelete: (threadId: string) => void; +} + +interface HookSectionState { + hooks: readonly HookItem[]; + warnings: readonly string[]; + errors: readonly string[]; + loaded: boolean; + loading: boolean; + status: string; + onTrust: (hook: HookItem) => void; + onToggleEnabled: (hook: HookItem, enabled: boolean) => void; +} + +export interface SettingsDynamicSectionsState { + helper: HelperSettingsState; + archived: ArchivedThreadSectionState; + hooks: HookSectionState; +} + +export function SettingsDynamicSections({ state }: { state: SettingsDynamicSectionsState }): UiNode { + return ( + <> + + + + + ); +} + +function HelperSettingsSection({ state }: { state: HelperSettingsState }): UiNode { + return ( +
+ + + + {state.modelLoadFailed ?

{state.modelStatus}

: null} +
+ ); +} + +function ModelEffortSetting({ + name, + desc, + modelValue, + effortValue, + models, + onModelChange, + onEffortChange, +}: { + name: string; + desc: string; + modelValue: string | null; + effortValue: ReasoningEffort | null; + models: readonly ModelMetadata[]; + onModelChange: (value: string | null) => void; + onEffortChange: (value: ReasoningEffort | null) => void; +}): UiNode { + const efforts = effortOptions(models, modelValue); + return ( + + { + onModelChange(value === CODEX_DEFAULT_VALUE ? null : value); + }} + > + {modelOptions(models, modelValue).map((option) => ( + + ))} + + { + onEffortChange(value === CODEX_DEFAULT_VALUE ? null : value); + }} + > + {reasoningEffortOptions(efforts, effortValue).map((option) => ( + + ))} + + + ); +} + +function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionState }): UiNode { + return ( +
+ + + {state.loaded && state.threads.length === 0 ? ( +

No archived threads.

+ ) : state.loaded ? ( + + ) : !state.loading && state.status ? ( +

{state.status}

+ ) : null} +
+ ); +} + +function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }): UiNode { + return ( + <> + + { + state.onExportEnabledChange(checked); + }} + /> + + + { + state.onExportFolderTemplateChange(value); + }} + /> + + + { + state.onExportFilenameTemplateChange(value); + }} + /> + + + { + state.onExportTagsChange(value); + }} + /> + + + ); +} + +function ArchivedThreadList({ state }: { state: ArchivedThreadSectionState }): UiNode { + return ( + <> +

+ Restore archived threads, or permanently delete archived threads you no longer need. +

+
+ {state.threads.map((thread) => ( + + ))} +
+ + ); +} + +function ArchivedThreadRow({ thread, state }: { thread: Thread; state: ArchivedThreadSectionState }): UiNode { + const title = archivedThreadDisplayTitle(thread); + const deleteConfirming = state.deleteConfirmThreadId === thread.id; + return ( + + {!deleteConfirming ? ( + { + state.onRestore(thread.id); + }} + /> + ) : null} + { + if (deleteConfirming) { + state.onDelete(thread.id); + } else { + state.onStartDelete(thread.id); + } + }} + /> + + ); +} + +function HookSection({ state }: { state: HookSectionState }): UiNode { + return ( +
+ + {state.loaded ? ( + + ) : !state.loading && state.status ? ( +

{state.status}

+ ) : null} +
+ ); +} + +function Hooks({ state }: { state: HookSectionState }): UiNode { + return ( + <> + {state.hooks.length === 0 ? ( +

No hooks found for this vault root.

+ ) : ( +
+ {state.hooks.map((hook) => ( + + ))} +
+ )} + {state.warnings.map((warning) => ( +

+ {warning} +

+ ))} + {state.errors.map((error) => ( +

+ {error} +

+ ))} + + ); +} + +function HookRow({ hook, state }: { hook: HookItem; state: HookSectionState }): UiNode { + const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified"); + const hookName = firstNonEmptyString(hook.statusMessage, hook.command, hook.matcher, hook.eventName); + return ( + {hook.currentHash}} + > + + + + ); +} + +function SettingsHeading({ name, desc, dynamic = false }: { name: string; desc?: string; dynamic?: boolean }): UiNode { + return ( +
+
+
+
{name}
+ {desc ?? null} +
+
+
+
+ ); +} + +function SettingRow({ + name, + desc, + className = "", + extraInfo, + children, +}: { + name: string; + desc: string; + className?: string; + extraInfo?: UiNode; + children: UiNode; +}): UiNode { + return ( +
+
+
+
{name}
+ {desc} + {extraInfo} +
+
+
{children}
+
+ ); +} + +function SettingsIconButton({ + icon, + label, + className, + onClick, +}: { + icon: string; + label: string; + className: string; + onClick: () => void; +}): UiNode { + return ( + ) => { + event.stopPropagation(); + }} + onClick={(event: TargetedMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onClick(); + }} + /> + ); +} + +function SelectControl({ value, onChange, children }: { value: string; onChange: (value: string) => void; children: UiNode }): UiNode { + return ( + + ); +} + +function TextControl({ value, placeholder, onChange }: { value: string; placeholder: string; onChange: (value: string) => void }): UiNode { + return ( + { + onChange(event.currentTarget.value); + }} + /> + ); +} + +function ToggleControl({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode { + return ( +
{ + if (event.target !== event.currentTarget) return; + onChange(!checked); + }} + > + { + onChange(event.currentTarget.checked); + }} + /> +
+ ); +} + +function modelOptions(models: readonly ModelMetadata[], current: string | null): { value: string; label: string }[] { + const options = [{ value: CODEX_DEFAULT_VALUE, label: "Codex default" }]; + if (current && !models.some((model) => model.model === current || model.id === current)) { + options.push({ value: current, label: `${current} (saved)` }); + } + for (const model of models) { + options.push({ value: model.model, label: model.model }); + } + return options; +} + +function effortOptions(models: readonly ModelMetadata[], modelIdOrName: string | null): ReasoningEffort[] { + const model = selectedModel(models, modelIdOrName); + return model ? supportedEffortsForModelMetadata(model) : []; +} + +function reasoningEffortOptions(efforts: readonly ReasoningEffort[], current: ReasoningEffort | null): { value: string; label: string }[] { + const options = [{ value: CODEX_DEFAULT_VALUE, label: "Codex default" }]; + if (current && !efforts.includes(current)) { + options.push({ value: current, label: `${current} (saved)` }); + } + for (const effort of efforts) { + options.push({ value: effort, label: effort }); + } + return options; +} + +function selectedModel(models: readonly ModelMetadata[], modelIdOrName: string | null): ModelMetadata | null { + if (!modelIdOrName) return null; + return models.find((model) => model.model === modelIdOrName || model.id === modelIdOrName) ?? null; +} + +function supportedEffortsForModelMetadata(model: ModelMetadata): ReasoningEffort[] { + return [...model.supportedReasoningEfforts]; +} + +function firstNonEmptyString(...values: (string | null | undefined)[]): string { + return ( + values.find((value): value is string => typeof value === "string" && value.length > 0) ?? + values.find((value): value is string => typeof value === "string") ?? + "" + ); +} + +function formatThreadDate(timestamp: number): string { + if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown"; + return new Date(timestamp * 1000).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/src/settings/render.tsx b/src/settings/render.tsx new file mode 100644 index 00000000..fcfd79c0 --- /dev/null +++ b/src/settings/render.tsx @@ -0,0 +1,10 @@ +import { renderUiRoot, unmountUiRoot } from "../shared/ui/ui-root"; +import { SettingsDynamicSections, type SettingsDynamicSectionsState } from "./dynamic-sections"; + +export function renderSettingsDynamicSections(container: HTMLElement, state: SettingsDynamicSectionsState): void { + renderUiRoot(container, ); +} + +export function unmountSettingsDynamicSections(container: HTMLElement | null): void { + unmountUiRoot(container); +} diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 297a62b6..b4f0827f 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -1,10 +1,11 @@ import { type App, Notice, type Plugin, PluginSettingTab, Setting, setIcon } from "obsidian"; import { DEFAULT_CODEX_PATH } from "../constants"; +import type { ReasoningEffort } from "../domain/catalog/metadata"; import { SettingsDynamicDataController, type SettingsDynamicDataHost } from "./dynamic-data-controller"; -import { renderArchivedThreadSection, renderHookSection } from "./dynamic-sections"; +import type { SettingsDynamicSectionsState } from "./dynamic-sections"; +import { renderSettingsDynamicSections, unmountSettingsDynamicSections } from "./render"; -const CODEX_DEFAULT_VALUE = "__codex-default__"; const SETTINGS_INTRO_TEXT = "Codex Panel stores only panel preferences. Models, sandboxing, approvals, MCP servers, hooks, and network access still come from Codex config."; const SEND_SHORTCUT_LABELS = { @@ -19,6 +20,7 @@ function renderSettingsHeading(containerEl: HTMLElement, name: string): void { export class CodexPanelSettingTab extends PluginSettingTab { private readonly dynamicData: SettingsDynamicDataController; private archivedDeleteConfirmThreadId: string | null = null; + private dynamicSectionsRoot: HTMLElement | null = null; private readonly cancelArchivedDeleteConfirmOnOutsidePointer = (event: PointerEvent): void => { if (!this.archivedDeleteConfirmThreadId) return; const target = event.target; @@ -28,7 +30,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { if (deleteConfirm && this.containerEl.contains(deleteConfirm)) return; } this.archivedDeleteConfirmThreadId = null; - this.renderSettingsTab({ autoLoadCodexData: false }); + this.renderDynamicSections(); }; constructor( @@ -39,7 +41,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { super(app, owner); this.dynamicData = new SettingsDynamicDataController(plugin, { display: () => { - this.display(); + this.renderDynamicSections(); }, notify: (message) => { new Notice(message); @@ -56,11 +58,15 @@ export class CodexPanelSettingTab extends PluginSettingTab { this.containerEl.removeEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer); this.archivedDeleteConfirmThreadId = null; this.dynamicData.dispose(); + unmountSettingsDynamicSections(this.dynamicSectionsRoot); + this.dynamicSectionsRoot = null; super.hide(); } private renderSettingsTab(options: { autoLoadCodexData: boolean }): void { const { containerEl } = this; + unmountSettingsDynamicSections(this.dynamicSectionsRoot); + this.dynamicSectionsRoot = null; containerEl.empty(); containerEl.addClass("codex-panel-settings"); containerEl.removeEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer); @@ -68,7 +74,8 @@ export class CodexPanelSettingTab extends PluginSettingTab { this.renderHeaderActions(containerEl, SETTINGS_INTRO_TEXT); this.renderPanelPreferenceSections(containerEl); - this.renderCodexDynamicSections(containerEl); + this.dynamicSectionsRoot = containerEl.createDiv({ cls: "codex-panel-settings__preact-sections" }); + this.renderDynamicSections(); if (options.autoLoadCodexData) this.maybeAutoLoadSettingsData(); } @@ -80,21 +87,17 @@ export class CodexPanelSettingTab extends PluginSettingTab { .setName("Codex executable") .setDesc("Path used to start `codex app-server`. Use an absolute path if Obsidian cannot find `codex`.") .addText((text) => { - text - .setPlaceholder(DEFAULT_CODEX_PATH) - .setValue(this.plugin.settings.codexPath) - .onChange(async (value) => { - const codexPath = value.trim() || DEFAULT_CODEX_PATH; - const codexPathChanged = codexPath !== this.plugin.settings.codexPath; - this.plugin.settings.codexPath = codexPath; - await this.plugin.saveSettings(); - if (codexPathChanged) { - this.dynamicData.resetSettingsDataContext(); - this.plugin.appServerData.notifyContextChanged(); - this.plugin.refreshOpenViews(); - this.renderSettingsTab({ autoLoadCodexData: false }); - } - }); + text.setPlaceholder(DEFAULT_CODEX_PATH).setValue(this.plugin.settings.codexPath); + const commitCodexPath = () => { + void this.setCodexPath(text.inputEl.value); + }; + text.inputEl.addEventListener("blur", commitCodexPath); + text.inputEl.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + commitCodexPath(); + text.inputEl.blur(); + }); }); new Setting(configSection) .setName("Show chat toolbar") @@ -132,133 +135,69 @@ export class CodexPanelSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }); }); - - const helperSection = containerEl.createDiv({ cls: "codex-panel-settings__section codex-panel-settings__helper-section" }); - renderSettingsHeading(helperSection, "Codex helpers"); - new Setting(helperSection) - .setName("Automatic thread naming") - .setDesc("Choose the model and reasoning effort used to suggest thread names.") - .addDropdown((dropdown) => { - const current = this.plugin.settings.threadNamingModel; - const options = this.dynamicData.modelMetadata(); - dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default"); - if (current && !options.some((model) => model.model === current || model.id === current)) { - dropdown.addOption(current, `${current} (saved)`); - } - for (const model of options) { - dropdown.addOption(model.model, model.model); - } - dropdown.setValue(current ?? CODEX_DEFAULT_VALUE).onChange(async (value) => { - this.plugin.settings.threadNamingModel = value === CODEX_DEFAULT_VALUE ? null : value; - if (!this.dynamicData.namingEffortSupported(this.plugin.settings.threadNamingEffort)) { - this.plugin.settings.threadNamingEffort = null; - } - await this.plugin.saveSettings(); - this.display(); - }); - }) - .addDropdown((dropdown) => { - const current = this.plugin.settings.threadNamingEffort; - const options = this.dynamicData.effortOptions(this.plugin.settings.threadNamingModel); - dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default"); - if (current && !options.includes(current)) { - dropdown.addOption(current, `${current} (saved)`); - } - for (const effort of options) { - dropdown.addOption(effort, effort); - } - dropdown.setValue(current ?? CODEX_DEFAULT_VALUE).onChange(async (value) => { - this.plugin.settings.threadNamingEffort = value === CODEX_DEFAULT_VALUE ? null : value; - await this.plugin.saveSettings(); - }); - }); - - new Setting(helperSection) - .setName("Selection rewrite") - .setDesc("Choose the model and reasoning effort used by rewrite selection.") - .addDropdown((dropdown) => { - const current = this.plugin.settings.rewriteSelectionModel; - const options = this.dynamicData.modelMetadata(); - dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default"); - if (current && !options.some((model) => model.model === current || model.id === current)) { - dropdown.addOption(current, `${current} (saved)`); - } - for (const model of options) { - dropdown.addOption(model.model, model.model); - } - dropdown.setValue(current ?? CODEX_DEFAULT_VALUE).onChange(async (value) => { - this.plugin.settings.rewriteSelectionModel = value === CODEX_DEFAULT_VALUE ? null : value; - if (!this.dynamicData.rewriteSelectionEffortSupported(this.plugin.settings.rewriteSelectionEffort)) { - this.plugin.settings.rewriteSelectionEffort = null; - } - await this.plugin.saveSettings(); - this.display(); - }); - }) - .addDropdown((dropdown) => { - const current = this.plugin.settings.rewriteSelectionEffort; - const options = this.dynamicData.effortOptions(this.plugin.settings.rewriteSelectionModel); - dropdown.addOption(CODEX_DEFAULT_VALUE, "Codex default"); - if (current && !options.includes(current)) { - dropdown.addOption(current, `${current} (saved)`); - } - for (const effort of options) { - dropdown.addOption(effort, effort); - } - dropdown.setValue(current ?? CODEX_DEFAULT_VALUE).onChange(async (value) => { - this.plugin.settings.rewriteSelectionEffort = value === CODEX_DEFAULT_VALUE ? null : value; - await this.plugin.saveSettings(); - }); - }); - const dynamicData = this.dynamicData.snapshot(); - if (dynamicData.modelsLifecycle.kind === "failed") { - configSection.createEl("p", { - cls: "setting-item-description codex-panel-settings__section-status", - text: dynamicData.modelsLifecycle.status, - }); - } } - private renderCodexDynamicSections(containerEl: HTMLElement): void { - const dynamicData = this.dynamicData.snapshot(); - renderArchivedThreadSection(containerEl, { - exportEnabled: this.plugin.settings.archiveExportEnabled, - exportFolderTemplate: this.plugin.settings.archiveExportFolderTemplate, - exportFilenameTemplate: this.plugin.settings.archiveExportFilenameTemplate, - exportTags: this.plugin.settings.archiveExportTags, - threads: dynamicData.archivedThreads, - loaded: dynamicData.archivedThreadsLifecycle.kind === "loaded", - loading: dynamicData.archivedThreadsLifecycle.kind === "loading", - status: dynamicData.archivedThreadsLifecycle.status, - deleteConfirmThreadId: this.archivedDeleteConfirmThreadId, - onExportEnabledChange: (enabled) => void this.setArchiveExportEnabled(enabled), - onExportFolderTemplateChange: (value) => void this.setArchiveExportFolderTemplate(value), - onExportFilenameTemplateChange: (value) => void this.setArchiveExportFilenameTemplate(value), - onExportTagsChange: (value) => void this.setArchiveExportTags(value), - onRestore: (threadId) => { - this.archivedDeleteConfirmThreadId = null; - void this.dynamicData.restoreArchivedThread(threadId); - }, - onStartDelete: (threadId) => { - this.archivedDeleteConfirmThreadId = threadId; - this.renderSettingsTab({ autoLoadCodexData: false }); - }, - onDelete: (threadId) => { - this.archivedDeleteConfirmThreadId = null; - void this.dynamicData.deleteArchivedThread(threadId); - }, - }); + private renderDynamicSections(): void { + if (!this.dynamicSectionsRoot) return; + renderSettingsDynamicSections(this.dynamicSectionsRoot, this.dynamicSectionsState()); + } - renderHookSection(containerEl, { - hooks: dynamicData.hooks, - warnings: dynamicData.hookWarnings, - errors: dynamicData.hookErrors, - loaded: dynamicData.hooksLifecycle.kind === "loaded", - loading: dynamicData.hooksLifecycle.kind === "loading", - status: dynamicData.hooksLifecycle.status, - onTrust: (hook) => void this.dynamicData.trustHook(hook), - onToggleEnabled: (hook, enabled) => void this.dynamicData.setHookEnabled(hook, enabled), - }); + private dynamicSectionsState(): SettingsDynamicSectionsState { + const dynamicData = this.dynamicData.snapshot(); + return { + helper: { + threadNamingModel: this.plugin.settings.threadNamingModel, + threadNamingEffort: this.plugin.settings.threadNamingEffort, + rewriteSelectionModel: this.plugin.settings.rewriteSelectionModel, + rewriteSelectionEffort: this.plugin.settings.rewriteSelectionEffort, + models: this.dynamicData.modelMetadata(), + modelLoadFailed: dynamicData.modelsLifecycle.kind === "failed", + modelStatus: dynamicData.modelsLifecycle.status, + onThreadNamingModelChange: (value) => void this.setThreadNamingModel(value), + onThreadNamingEffortChange: (value) => void this.setThreadNamingEffort(value), + onRewriteSelectionModelChange: (value) => void this.setRewriteSelectionModel(value), + onRewriteSelectionEffortChange: (value) => void this.setRewriteSelectionEffort(value), + }, + archived: { + exportEnabled: this.plugin.settings.archiveExportEnabled, + exportFolderTemplate: this.plugin.settings.archiveExportFolderTemplate, + exportFilenameTemplate: this.plugin.settings.archiveExportFilenameTemplate, + exportTags: this.plugin.settings.archiveExportTags, + threads: dynamicData.archivedThreads, + loaded: dynamicData.archivedThreadsLifecycle.kind === "loaded", + loading: dynamicData.archivedThreadsLifecycle.kind === "loading", + status: dynamicData.archivedThreadsLifecycle.status, + deleteConfirmThreadId: this.archivedDeleteConfirmThreadId, + onExportEnabledChange: (enabled) => void this.setArchiveExportEnabled(enabled), + onExportFolderTemplateChange: (value) => void this.setArchiveExportFolderTemplate(value), + onExportFilenameTemplateChange: (value) => void this.setArchiveExportFilenameTemplate(value), + onExportTagsChange: (value) => void this.setArchiveExportTags(value), + onRestore: (threadId) => { + this.archivedDeleteConfirmThreadId = null; + this.renderDynamicSections(); + void this.dynamicData.restoreArchivedThread(threadId); + }, + onStartDelete: (threadId) => { + this.archivedDeleteConfirmThreadId = threadId; + this.renderDynamicSections(); + }, + onDelete: (threadId) => { + this.archivedDeleteConfirmThreadId = null; + this.renderDynamicSections(); + void this.dynamicData.deleteArchivedThread(threadId); + }, + }, + hooks: { + hooks: dynamicData.hooks, + warnings: dynamicData.hookWarnings, + errors: dynamicData.hookErrors, + loaded: dynamicData.hooksLifecycle.kind === "loaded", + loading: dynamicData.hooksLifecycle.kind === "loading", + status: dynamicData.hooksLifecycle.status, + onTrust: (hook) => void this.dynamicData.trustHook(hook), + onToggleEnabled: (hook, enabled) => void this.dynamicData.setHookEnabled(hook, enabled), + }, + }; } private renderHeaderActions(containerEl: HTMLElement, introText: string): void { @@ -283,10 +222,21 @@ export class CodexPanelSettingTab extends PluginSettingTab { this.dynamicData.maybeAutoLoadSettingsData(); } + private async setCodexPath(value: string): Promise { + const codexPath = value.trim() || DEFAULT_CODEX_PATH; + if (codexPath === this.plugin.settings.codexPath) return; + this.plugin.settings.codexPath = codexPath; + await this.plugin.saveSettings(); + this.dynamicData.resetSettingsDataContext(); + this.plugin.appServerData.notifyContextChanged(); + this.plugin.refreshOpenViews(); + this.renderSettingsTab({ autoLoadCodexData: false }); + } + private async setArchiveExportEnabled(enabled: boolean): Promise { this.plugin.settings.archiveExportEnabled = enabled; await this.plugin.saveSettings(); - this.display(); + this.renderDynamicSections(); } private async setArchiveExportFolderTemplate(value: string): Promise { @@ -303,6 +253,34 @@ export class CodexPanelSettingTab extends PluginSettingTab { this.plugin.settings.archiveExportTags = value.trim(); await this.plugin.saveSettings(); } + + private async setThreadNamingModel(value: string | null): Promise { + this.plugin.settings.threadNamingModel = value; + if (!this.dynamicData.namingEffortSupported(this.plugin.settings.threadNamingEffort)) { + this.plugin.settings.threadNamingEffort = null; + } + await this.plugin.saveSettings(); + this.renderDynamicSections(); + } + + private async setThreadNamingEffort(value: ReasoningEffort | null): Promise { + this.plugin.settings.threadNamingEffort = value; + await this.plugin.saveSettings(); + } + + private async setRewriteSelectionModel(value: string | null): Promise { + this.plugin.settings.rewriteSelectionModel = value; + if (!this.dynamicData.rewriteSelectionEffortSupported(this.plugin.settings.rewriteSelectionEffort)) { + this.plugin.settings.rewriteSelectionEffort = null; + } + await this.plugin.saveSettings(); + this.renderDynamicSections(); + } + + private async setRewriteSelectionEffort(value: ReasoningEffort | null): Promise { + this.plugin.settings.rewriteSelectionEffort = value; + await this.plugin.saveSettings(); + } } export interface CodexPanelSettingTabHost extends SettingsDynamicDataHost { diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index c4d2715f..a346db83 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -151,6 +151,10 @@ describe("settings tab", () => { expect(folder.getAttribute("aria-label")).toBeNull(); expect(filename.getAttribute("aria-label")).toBeNull(); expect(tags.getAttribute("aria-label")).toBeNull(); + expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true); + expect(folder.type).toBe("text"); + expect(filename.type).toBe("text"); + expect(tags.type).toBe("text"); toggle.checked = true; toggle.dispatchEvent(new Event("change")); @@ -232,6 +236,13 @@ describe("settings tab", () => { codexInput.dispatchEvent(new Event("change")); await flushPromises(); + expect(saveSettings).not.toHaveBeenCalled(); + expect(notifyContextChanged).not.toHaveBeenCalled(); + expect(refreshOpenViews).not.toHaveBeenCalled(); + + codexInput.dispatchEvent(new FocusEvent("blur")); + await flushPromises(); + expect(saveSettings).toHaveBeenCalledOnce(); expect(notifyContextChanged).toHaveBeenCalledOnce(); expect(refreshOpenViews).toHaveBeenCalledOnce(); @@ -447,6 +458,8 @@ describe("settings tab", () => { expect(selectOptions(tab, "Automatic thread naming", 1)).toEqual(["Codex default", "saved-custom-effort (saved)", "extreme"]); expect(selectOptions(tab, "Selection rewrite", 1)).toEqual(["Codex default", "extreme"]); + expect(selectForSetting(tab, "Automatic thread naming")?.classList.contains("dropdown")).toBe(true); + expect(selectForSetting(tab, "Selection rewrite")?.classList.contains("dropdown")).toBe(true); }); it("keeps successful sections when one settings data request fails", async () => { @@ -525,6 +538,39 @@ describe("settings tab", () => { expect(client.deleteThread).not.toHaveBeenCalled(); }); + it("keeps the settings shell mounted while dynamic sections update", async () => { + const saveSettings = vi.fn().mockResolvedValue(undefined); + const client = settingsClient({ + threads: [appServerThread({ id: "thread-archived", preview: "Archived thread" })], + }); + withShortLivedAppServerClientMock.mockImplementation( + (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), + ); + const tab = newSettingsTab({ saveSettings }); + + tab.display(); + await flushPromises(); + + const codexInput = inputForSetting(tab, "Codex executable"); + const shortcut = selectForSetting(tab, "Send shortcut"); + const archiveToggle = inputForSetting(tab, "Save note by default"); + if (!codexInput || !shortcut || !archiveToggle) throw new Error("Missing settings controls"); + + archiveToggle.checked = true; + archiveToggle.dispatchEvent(new Event("change")); + await flushPromises(); + + expect(saveSettings).toHaveBeenCalledOnce(); + expect(inputForSetting(tab, "Codex executable")).toBe(codexInput); + expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut); + + clickButtonByLabel(tab, "Delete Archived thread"); + + expect(inputForSetting(tab, "Codex executable")).toBe(codexInput); + expect(selectForSetting(tab, "Send shortcut")).toBe(shortcut); + expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).not.toBeNull(); + }); + it("permanently deletes an archived thread from the confirmed settings row", async () => { const invalidateThreadsFromOpenSurface = vi.fn(); const client = settingsClient({ @@ -539,6 +585,7 @@ describe("settings tab", () => { await flushPromises(); clickButtonByLabel(tab, "Delete Archived thread"); + pointerDownButtonByLabel(tab, "Confirm permanent delete Archived thread"); clickButtonByLabel(tab, "Confirm permanent delete Archived thread"); await flushPromises(); @@ -730,7 +777,7 @@ async function flushPromises(): Promise { } function settingNames(tab: CodexPanelSettingTab): string[] { - return Array.from(tab.containerEl.children).flatMap((element) => { + return Array.from(settingsSectionRoots(tab)).flatMap((element) => { if (element.classList.contains("setting-item")) { return [element.querySelector(".setting-item-name")?.textContent ?? ""]; } @@ -746,6 +793,13 @@ function settingNames(tab: CodexPanelSettingTab): string[] { }); } +function settingsSectionRoots(tab: CodexPanelSettingTab): Element[] { + return Array.from(tab.containerEl.children).flatMap((element) => { + if (element.classList.contains("codex-panel-settings__preact-sections")) return Array.from(element.children); + return [element]; + }); +} + function settingDesc(tab: CodexPanelSettingTab, name: string): string { const setting = Array.from(tab.containerEl.querySelectorAll(".setting-item")).find( (element) => element.querySelector(".setting-item-name")?.textContent === name, @@ -762,9 +816,17 @@ function buttonLabels(tab: CodexPanelSettingTab): string[] { } function clickButtonByLabel(tab: CodexPanelSettingTab, label: string): void { + buttonByLabel(tab, label).click(); +} + +function pointerDownButtonByLabel(tab: CodexPanelSettingTab, label: string): void { + buttonByLabel(tab, label).dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); +} + +function buttonByLabel(tab: CodexPanelSettingTab, label: string): HTMLButtonElement { const button = Array.from(tab.containerEl.querySelectorAll("button")).find((element) => element.ariaLabel === label); if (!button) throw new Error(`Could not find button: ${label}`); - button.click(); + return button; } function inputForSetting(tab: CodexPanelSettingTab, name: string): HTMLInputElement | null {