Improve settings Codex data refresh UI

This commit is contained in:
murashit 2026-05-28 19:20:34 +09:00
parent 3b6077ab51
commit d72ceacd94
6 changed files with 122 additions and 42 deletions

View file

@ -28,6 +28,26 @@ export type SettledSettingsData<T> =
status: string;
};
export type SettingsDataRefreshLifecycleState = { kind: "idle" } | { kind: "loading" } | { kind: "completed"; failedCount: number };
export type SettingsDataRefreshLifecycleEvent = { type: "started" } | { type: "completed"; failedCount: number };
export function transitionSettingsDataRefreshLifecycle(
state: SettingsDataRefreshLifecycleState,
event: SettingsDataRefreshLifecycleEvent,
): SettingsDataRefreshLifecycleState {
switch (event.type) {
case "started":
return state.kind === "loading" ? state : { kind: "loading" };
case "completed":
return { kind: "completed", failedCount: event.failedCount };
}
}
export function settingsDataRefreshLoading(state: SettingsDataRefreshLifecycleState): boolean {
return state.kind === "loading";
}
export async function loadSettingsData(client: AppServerClient, cwd: string): Promise<SettingsDataLoad> {
const [modelsResult, hooksResult, archivedThreadsResult] = await Promise.allSettled([
client.listModels(false),

View file

@ -133,13 +133,9 @@ function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedTh
}
function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
const summary = containerEl.createEl("p", {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
});
summary.createSpan({ text: "Restore archived threads to the active thread list." });
summary.createSpan({
cls: "codex-panel-settings__dynamic-list-count",
text: `Loaded ${String(state.threads.length)} archived thread${state.threads.length === 1 ? "" : "s"} from Codex app server.`,
text: "Restore archived threads to the active thread list.",
});
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" });
for (const thread of state.threads) {
@ -163,10 +159,6 @@ 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 {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
text: `Loaded ${String(state.hooks.length)} hook${state.hooks.length === 1 ? "" : "s"} from Codex app server.`,
});
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);

View file

@ -1,4 +1,4 @@
import { type App, Notice, type Plugin, PluginSettingTab, Setting } from "obsidian";
import { type App, Notice, type Plugin, PluginSettingTab, Setting, setIcon } from "obsidian";
import type { AppServerClient } from "../app-server/client";
import { withAppServerConnection } from "../app-server/connection-client";
@ -10,7 +10,13 @@ import type { Thread } from "../generated/app-server/v2/Thread";
import { findModelByIdOrName, REASONING_EFFORTS, sortedAvailableModels, supportedEffortsForModel } from "../runtime/model";
import { archivedThreadDisplayTitle } from "../domain/threads/model";
import { errorMessage } from "../utils";
import { loadHookData, loadSettingsData } from "./data";
import {
loadHookData,
loadSettingsData,
settingsDataRefreshLoading,
transitionSettingsDataRefreshLifecycle,
type SettingsDataRefreshLifecycleState,
} from "./data";
import { renderArchivedThreadSection, renderHookSection } from "./dynamic-sections";
import type { CodexPanelSettings } from "./model";
@ -24,9 +30,15 @@ function renderSettingsHeading(containerEl: HTMLElement, name: string): void {
new Setting(containerEl).setClass("codex-panel-settings__section-heading").setHeading().setName(name);
}
function settingsDataRefreshStatus(state: SettingsDataRefreshLifecycleState): string {
if (state.kind === "loading") return "Refreshing Codex data...";
if (state.kind === "completed" && state.failedCount > 0) return "Could not refresh all Codex data.";
return "";
}
export class CodexPanelSettingTab extends PluginSettingTab {
private settingsDataAutoLoadStarted = false;
private settingsDataLoading = false;
private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" };
private archivedThreads: Thread[] = [];
private archivedThreadsLoaded = false;
private archivedThreadsLoading = false;
@ -54,6 +66,8 @@ export class CodexPanelSettingTab extends PluginSettingTab {
containerEl.empty();
containerEl.addClass("codex-panel-settings");
this.renderHeaderActions(containerEl);
const configSection = containerEl.createDiv({ cls: "codex-panel-settings__section codex-panel-settings__general-section" });
configSection.createEl("p", {
cls: "setting-item-description codex-panel-settings__section-intro",
@ -73,16 +87,6 @@ export class CodexPanelSettingTab extends PluginSettingTab {
});
});
new Setting(configSection)
.setName("Codex data")
.setDesc("Refresh models, hooks, and archived threads from Codex app server.")
.addButton((button) => {
button
.setButtonText(this.settingsDataLoading ? "Refreshing..." : "Refresh Codex data")
.setDisabled(this.settingsDataLoading)
.onClick(() => void this.refreshSettingsData());
});
const composerSection = containerEl.createDiv({ cls: "codex-panel-settings__section codex-panel-settings__composer-section" });
renderSettingsHeading(composerSection, "Composer");
new Setting(composerSection)
@ -213,14 +217,33 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.maybeAutoLoadSettingsData();
}
private renderHeaderActions(containerEl: HTMLElement): void {
const header = containerEl.createDiv({ cls: "codex-panel-settings__header" });
const status = settingsDataRefreshStatus(this.settingsDataRefreshLifecycle);
header.createEl("span", {
cls: "setting-item-description codex-panel-settings__refresh-status",
text: status,
});
const button = header.createEl("button", {
cls: "clickable-icon codex-panel-settings__refresh-button",
});
button.type = "button";
button.disabled = this.settingsDataLoading();
button.ariaLabel = this.settingsDataLoading() ? "Refreshing Codex data" : "Refresh Codex data";
setIcon(button, "refresh-cw");
button.addEventListener("click", () => {
void this.refreshSettingsData();
});
}
private maybeAutoLoadSettingsData(): void {
if (this.settingsDataAutoLoadStarted || this.settingsDataLoading) return;
if (this.settingsDataAutoLoadStarted || this.settingsDataLoading()) return;
this.settingsDataAutoLoadStarted = true;
void this.refreshSettingsData();
}
private async refreshSettingsData(): Promise<void> {
this.settingsDataLoading = true;
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, { type: "started" });
this.modelsLoading = true;
this.archivedThreadsLoading = true;
this.hooksLoading = true;
@ -268,7 +291,10 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.hooksStatus = `Could not load hooks: ${message}`;
this.archivedThreadsStatus = `Could not load archived threads: ${message}`;
} finally {
this.settingsDataLoading = false;
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
type: "completed",
failedCount,
});
this.modelsLoading = false;
this.archivedThreadsLoading = false;
this.hooksLoading = false;
@ -279,6 +305,10 @@ export class CodexPanelSettingTab extends PluginSettingTab {
}
}
private settingsDataLoading(): boolean {
return settingsDataRefreshLoading(this.settingsDataRefreshLifecycle);
}
private async loadHooks(): Promise<void> {
this.hooksLoading = true;
this.hooksStatus = "";

View file

@ -671,6 +671,27 @@
margin-top: var(--size-4-6);
}
.codex-panel-settings__header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--size-4-2);
min-height: var(--input-height);
}
.codex-panel-settings__refresh-status {
color: var(--text-muted);
}
.codex-panel-settings__refresh-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--input-height);
height: var(--input-height);
padding: 0;
}
.codex-panel-settings__section-heading.setting-item-heading,
.codex-panel-settings__dynamic-section-heading.setting-item-heading {
margin-bottom: var(--size-4-2);
@ -696,13 +717,6 @@
margin: 0 0 var(--size-2-3);
}
.codex-panel-settings__dynamic-list-count {
display: block;
margin-top: var(--size-2-1);
color: var(--text-faint);
font-size: var(--font-ui-smaller);
}
.codex-panel-settings__dynamic-list {
margin: var(--size-2-3) 0 0;
overflow: auto;

View file

@ -1,9 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../src/app-server/client";
import { loadHookData, loadSettingsData } from "../../src/settings/data";
import {
loadHookData,
loadSettingsData,
settingsDataRefreshLoading,
transitionSettingsDataRefreshLifecycle,
} from "../../src/settings/data";
describe("settings data", () => {
it("tracks settings data refresh lifecycle", () => {
const idle = { kind: "idle" } as const;
const loading = transitionSettingsDataRefreshLifecycle(idle, { type: "started" });
expect(loading).toEqual({ kind: "loading" });
expect(settingsDataRefreshLoading(loading)).toBe(true);
expect(transitionSettingsDataRefreshLifecycle(loading, { type: "started" })).toBe(loading);
const completed = transitionSettingsDataRefreshLifecycle(loading, { type: "completed", failedCount: 2 });
expect(completed).toEqual({ kind: "completed", failedCount: 2 });
expect(settingsDataRefreshLoading(completed)).toBe(false);
});
it("uses only hook rows for the requested cwd", async () => {
const client = {
listHooks: vi.fn().mockResolvedValue({

View file

@ -85,14 +85,15 @@ describe("settings tab", () => {
await flushPromises();
expect(withAppServerConnectionMock).toHaveBeenCalledTimes(1);
expect(buttonTexts(tab)).toContain("Refresh Codex data");
expect(buttonLabels(tab)).toContain("Refresh Codex data");
expect(buttonTexts(tab)).not.toContain("Refresh Codex data");
expect(buttonTexts(tab)).not.toContain("Load models");
expect(buttonTexts(tab)).not.toContain("Load hooks");
expect(buttonTexts(tab)).not.toContain("Load archive list");
expect(tab.containerEl.querySelector(".codex-panel-settings__header button")?.getAttribute("data-icon")).toBe("refresh-cw");
expect(tab.containerEl.querySelector("h2")).toBeNull();
expect(settingNames(tab)).toEqual([
"Codex executable",
"Codex data",
"Composer",
"Send shortcut",
"Codex helpers",
@ -165,7 +166,7 @@ describe("settings tab", () => {
tab.display();
await flushPromises();
clickButton(tab, "Refresh Codex data");
clickButtonByLabel(tab, "Refresh Codex data");
await flushPromises();
expect(withAppServerConnectionMock).toHaveBeenCalledTimes(2);
@ -208,6 +209,7 @@ describe("settings tab", () => {
expect(tab.containerEl.textContent).not.toContain("Loaded 1 model.");
expect(tab.containerEl.textContent).toContain("Could not load hooks: hooks unavailable");
expect(tab.containerEl.querySelector(".codex-panel-settings__refresh-status")?.textContent).toBe("Could not refresh all Codex data.");
expect(tab.containerEl.textContent).toContain("Archived thread");
expect(notices).toEqual(["Could not refresh all Codex data."]);
});
@ -225,9 +227,9 @@ describe("settings tab", () => {
tab.display();
await flushPromises();
expect(tab.containerEl.textContent).toContain("Loaded 1 hook from Codex app server.");
expect(tab.containerEl.textContent).toContain("Restore archived threads to the active thread list.");
expect(tab.containerEl.textContent).toContain("Loaded 1 archived thread from Codex app server.");
expect(tab.containerEl.textContent).not.toContain("Loaded 1 hook from Codex app server.");
expect(tab.containerEl.textContent).not.toContain("Loaded 1 archived thread from Codex app server.");
expect(tab.containerEl.querySelector(".codex-panel-settings__hook-section .setting-item-heading")?.textContent).toContain(
"Hook status",
);
@ -393,9 +395,13 @@ function buttonTexts(tab: CodexPanelSettingTab): string[] {
return Array.from(tab.containerEl.querySelectorAll("button")).map((element) => element.textContent);
}
function clickButton(tab: CodexPanelSettingTab, text: string): void {
const button = Array.from(tab.containerEl.querySelectorAll("button")).find((element) => element.textContent === text);
if (!button) throw new Error(`Could not find button: ${text}`);
function buttonLabels(tab: CodexPanelSettingTab): string[] {
return Array.from(tab.containerEl.querySelectorAll("button")).map((element) => element.ariaLabel ?? "");
}
function clickButtonByLabel(tab: CodexPanelSettingTab, label: string): void {
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();
}