mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Render settings dynamic sections with Preact
This commit is contained in:
parent
b68f2d1fb8
commit
2e2314633b
6 changed files with 712 additions and 388 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
512
src/settings/dynamic-sections.tsx
Normal file
512
src/settings/dynamic-sections.tsx
Normal 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
10
src/settings/render.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue