Render settings dynamic sections with Preact

This commit is contained in:
murashit 2026-06-16 15:06:45 +09:00
parent b68f2d1fb8
commit 2e2314633b
6 changed files with 712 additions and 388 deletions

View file

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

View file

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

View file

@ -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 (
<>
<HelperSettingsSection state={state.helper} />
<ArchivedThreadSection state={state.archived} />
<HookSection state={state.hooks} />
</>
);
}
function HelperSettingsSection({ state }: { state: HelperSettingsState }): UiNode {
return (
<section className="codex-panel-settings__section codex-panel-settings__helper-section">
<SettingsHeading name="Codex helpers" />
<ModelEffortSetting
name="Automatic thread naming"
desc="Choose the model and reasoning effort used to suggest thread names."
modelValue={state.threadNamingModel}
effortValue={state.threadNamingEffort}
models={state.models}
onModelChange={state.onThreadNamingModelChange}
onEffortChange={state.onThreadNamingEffortChange}
/>
<ModelEffortSetting
name="Selection rewrite"
desc="Choose the model and reasoning effort used by rewrite selection."
modelValue={state.rewriteSelectionModel}
effortValue={state.rewriteSelectionEffort}
models={state.models}
onModelChange={state.onRewriteSelectionModelChange}
onEffortChange={state.onRewriteSelectionEffortChange}
/>
{state.modelLoadFailed ? <p className="setting-item-description codex-panel-settings__section-status">{state.modelStatus}</p> : null}
</section>
);
}
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 (
<SettingRow name={name} desc={desc}>
<SelectControl
value={modelValue ?? CODEX_DEFAULT_VALUE}
onChange={(value) => {
onModelChange(value === CODEX_DEFAULT_VALUE ? null : value);
}}
>
{modelOptions(models, modelValue).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</SelectControl>
<SelectControl
value={effortValue ?? CODEX_DEFAULT_VALUE}
onChange={(value) => {
onEffortChange(value === CODEX_DEFAULT_VALUE ? null : value);
}}
>
{reasoningEffortOptions(efforts, effortValue).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</SelectControl>
</SettingRow>
);
}
function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionState }): UiNode {
return (
<section className="codex-panel-settings__dynamic-section codex-panel-settings__archived-section">
<SettingsHeading
dynamic
name="Thread archiving"
desc="Choose the default archive behavior and configure saved thread notes. Thread lists offer both archive choices; slash commands use the default."
/>
<ArchiveExportSettings state={state} />
{state.loaded && state.threads.length === 0 ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">No archived threads.</p>
) : state.loaded ? (
<ArchivedThreadList state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null}
</section>
);
}
function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }): UiNode {
return (
<>
<SettingRow
name="Save note by default"
desc="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."
>
<ToggleControl
checked={state.exportEnabled}
onChange={(checked) => {
state.onExportEnabledChange(checked);
}}
/>
</SettingRow>
<SettingRow name="Saved note folder" desc="Vault-relative folder for saved thread notes. The folder is created when needed.">
<TextControl
placeholder="Codex archives"
value={state.exportFolderTemplate}
onChange={(value) => {
state.onExportFolderTemplateChange(value);
}}
/>
</SettingRow>
<SettingRow
name="Saved note filename"
desc="Filename template. Variables: {{date}}, {{time}}, {{title}}, {{id}}, {{shortId}}. Existing files get a numeric suffix."
>
<TextControl
placeholder="{{date}} {{time}} {{title}} {{shortId}}.md"
value={state.exportFilenameTemplate}
onChange={(value) => {
state.onExportFilenameTemplateChange(value);
}}
/>
</SettingRow>
<SettingRow name="Saved note tags" desc="Comma-separated fixed tags for saved notes. Leave empty to omit tags.">
<TextControl
placeholder="Codex, archive"
value={state.exportTags}
onChange={(value) => {
state.onExportTagsChange(value);
}}
/>
</SettingRow>
</>
);
}
function ArchivedThreadList({ state }: { state: ArchivedThreadSectionState }): UiNode {
return (
<>
<p className="setting-item-description codex-panel-settings__dynamic-list-summary">
Restore archived threads, or permanently delete archived threads you no longer need.
</p>
<div className="setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list">
{state.threads.map((thread) => (
<ArchivedThreadRow key={thread.id} thread={thread} state={state} />
))}
</div>
</>
);
}
function ArchivedThreadRow({ thread, state }: { thread: Thread; state: ArchivedThreadSectionState }): UiNode {
const title = archivedThreadDisplayTitle(thread);
const deleteConfirming = state.deleteConfirmThreadId === thread.id;
return (
<SettingRow
className={`codex-panel-settings__dynamic-row codex-panel-settings__archived-row ${
deleteConfirming ? "codex-panel-settings__archived-row--delete-confirming" : ""
}`}
name={title}
desc={
deleteConfirming
? "Permanently delete this archived thread? This cannot be undone."
: `Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`
}
>
{!deleteConfirming ? (
<SettingsIconButton
icon="rotate-ccw"
label={`Restore ${title}`}
className="codex-panel-settings__archived-restore"
onClick={() => {
state.onRestore(thread.id);
}}
/>
) : null}
<SettingsIconButton
icon={deleteConfirming ? "check" : "shredder"}
label={deleteConfirming ? `Confirm permanent delete ${title}` : `Delete ${title}`}
className={deleteConfirming ? "codex-panel-settings__archived-delete-confirm" : "codex-panel-settings__archived-delete"}
onClick={() => {
if (deleteConfirming) {
state.onDelete(thread.id);
} else {
state.onStartDelete(thread.id);
}
}}
/>
</SettingRow>
);
}
function HookSection({ state }: { state: HookSectionState }): UiNode {
return (
<section className="codex-panel-settings__dynamic-section codex-panel-settings__hook-section">
<SettingsHeading dynamic name="Hook status" desc="Review discovered hooks, trust changes, and turn hooks on or off." />
{state.loaded ? (
<Hooks state={state} />
) : !state.loading && state.status ? (
<p className="setting-item-description codex-panel-settings__dynamic-section-status">{state.status}</p>
) : null}
</section>
);
}
function Hooks({ state }: { state: HookSectionState }): UiNode {
return (
<>
{state.hooks.length === 0 ? (
<p className="setting-item-description">No hooks found for this vault root.</p>
) : (
<div className="setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list">
{state.hooks.map((hook) => (
<HookRow key={hook.key} hook={hook} state={state} />
))}
</div>
)}
{state.warnings.map((warning) => (
<p key={`warning:${warning}`} className="setting-item-description codex-panel-settings__hook-warning">
{warning}
</p>
))}
{state.errors.map((error) => (
<p key={`error:${error}`} className="setting-item-description codex-panel-settings__hook-error">
{error}
</p>
))}
</>
);
}
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 (
<SettingRow
className="codex-panel-settings__dynamic-row codex-panel-settings__hook-row"
name={hookName}
desc={`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`}
extraInfo={<div className="codex-panel-settings__hook-hash">{hook.currentHash}</div>}
>
<button
type="button"
disabled={state.loading || !canTrust}
onClick={() => {
state.onTrust(hook);
}}
>
Trust
</button>
<button
type="button"
disabled={state.loading || hook.isManaged}
onClick={() => {
state.onToggleEnabled(hook, !hook.enabled);
}}
>
{hook.enabled ? "Disable" : "Enable"}
</button>
</SettingRow>
);
}
function SettingsHeading({ name, desc, dynamic = false }: { name: string; desc?: string; dynamic?: boolean }): UiNode {
return (
<div
className={`${dynamic ? "codex-panel-settings__dynamic-section-heading" : "codex-panel-settings__section-heading"} setting-item setting-item-heading`}
>
<div className="setting-item-info">
<div className="setting-item-description">
<div className="setting-item-name">{name}</div>
{desc ?? null}
</div>
</div>
<div className="setting-item-control" />
</div>
);
}
function SettingRow({
name,
desc,
className = "",
extraInfo,
children,
}: {
name: string;
desc: string;
className?: string;
extraInfo?: UiNode;
children: UiNode;
}): UiNode {
return (
<div className={`setting-item ${className}`.trim()}>
<div className="setting-item-info">
<div className="setting-item-description">
<div className="setting-item-name">{name}</div>
{desc}
{extraInfo}
</div>
</div>
<div className="setting-item-control">{children}</div>
</div>
);
}
function SettingsIconButton({
icon,
label,
className,
onClick,
}: {
icon: string;
label: string;
className: string;
onClick: () => void;
}): UiNode {
return (
<IconButton
icon={icon}
label={label}
className={`clickable-icon extra-setting-button ${className}`}
onPointerDown={(event: TargetedPointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
}}
onClick={(event: TargetedMouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onClick();
}}
/>
);
}
function SelectControl({ value, onChange, children }: { value: string; onChange: (value: string) => void; children: UiNode }): UiNode {
return (
<select
className="dropdown"
value={value}
onChange={(event) => {
onChange(event.currentTarget.value);
}}
>
{children}
</select>
);
}
function TextControl({ value, placeholder, onChange }: { value: string; placeholder: string; onChange: (value: string) => void }): UiNode {
return (
<input
type="text"
placeholder={placeholder}
value={value}
onChange={(event) => {
onChange(event.currentTarget.value);
}}
/>
);
}
function ToggleControl({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode {
return (
<div
className={`checkbox-container ${checked ? "is-enabled" : ""}`}
onClick={(event) => {
if (event.target !== event.currentTarget) return;
onChange(!checked);
}}
>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
onChange(event.currentTarget.checked);
}}
/>
</div>
);
}
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",
});
}

10
src/settings/render.tsx Normal file
View file

@ -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, <SettingsDynamicSections state={state} />);
}
export function unmountSettingsDynamicSections(container: HTMLElement | null): void {
unmountUiRoot(container);
}

View file

@ -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<void> {
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<void> {
this.plugin.settings.archiveExportEnabled = enabled;
await this.plugin.saveSettings();
this.display();
this.renderDynamicSections();
}
private async setArchiveExportFolderTemplate(value: string): Promise<void> {
@ -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<void> {
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<void> {
this.plugin.settings.threadNamingEffort = value;
await this.plugin.saveSettings();
}
private async setRewriteSelectionModel(value: string | null): Promise<void> {
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<void> {
this.plugin.settings.rewriteSelectionEffort = value;
await this.plugin.saveSettings();
}
}
export interface CodexPanelSettingTabHost extends SettingsDynamicDataHost {

View file

@ -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<unknown>) => 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<void> {
}
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 {