Delete archived threads from settings

This commit is contained in:
murashit 2026-06-16 14:22:56 +09:00
parent e59a782705
commit 6a5fcc67df
8 changed files with 171 additions and 7 deletions

View file

@ -91,7 +91,7 @@ By default, `Enter` sends and `Shift+Enter` inserts a newline. You can switch se
- Send resolved wikilinks as Codex file mentions when the target exists.
- Open rendered Markdown links to vault files in Obsidian.
- Review Codex file changes in an Obsidian diff view with changed files and a copy-diff action.
- Archive threads as Markdown notes with configurable folder, filename template, and tags, or archive without saving.
- Archive threads as Markdown notes with configurable folder, filename template, and tags, archive without saving, then restore or permanently delete archived threads from settings.
- Rewrite selected note text using the current editor buffer as context, then review the replacement diff before applying it.
Rollback restores the latest prompt in the Codex thread. It does not undo file edits in your vault.

View file

@ -16,6 +16,7 @@ import type { ModelListResponse } from "../../generated/app-server/v2/ModelListR
import type { ModelProviderCapabilitiesReadResponse } from "../../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
import type { ThreadArchiveResponse } from "../../generated/app-server/v2/ThreadArchiveResponse";
import type { ThreadDeleteResponse } from "../../generated/app-server/v2/ThreadDeleteResponse";
import type { ThreadForkResponse } from "../../generated/app-server/v2/ThreadForkResponse";
import type { ThreadGoalClearResponse } from "../../generated/app-server/v2/ThreadGoalClearResponse";
import type { ThreadGoalGetResponse } from "../../generated/app-server/v2/ThreadGoalGetResponse";
@ -127,6 +128,7 @@ interface ClientResponseByMethod {
"thread/list": ThreadListResponse;
"thread/read": ThreadReadResponse;
"thread/archive": ThreadArchiveResponse;
"thread/delete": ThreadDeleteResponse;
"thread/unarchive": ThreadUnarchiveResponse;
"thread/rollback": ThreadRollbackResponse;
"thread/name/set": ThreadSetNameResponse;
@ -324,6 +326,10 @@ export class AppServerClient {
return this.request("thread/archive", { threadId });
}
deleteThread(threadId: string): Promise<ThreadDeleteResponse> {
return this.request("thread/delete", { threadId });
}
readThread(threadId: string, includeTurns = true): Promise<ThreadReadResponse> {
return this.request("thread/read", { threadId, includeTurns });
}

View file

@ -94,3 +94,7 @@ export async function restoreArchivedThread(client: AppServerClient, threadId: s
const response = await client.unarchiveThread(threadId);
return threadFromThreadRecord(response.thread);
}
export async function deleteArchivedThread(client: AppServerClient, threadId: string): Promise<void> {
await client.deleteThread(threadId);
}

View file

@ -3,7 +3,10 @@ import type { AppServerObservedQueryResult } from "../app-server/query/cache";
import { isStaleAppServerSharedQueryContextError } from "../app-server/query/shared-queries";
import { withShortLivedAppServerClient } from "../app-server/connection/short-lived-client";
import { setHookItemEnabled, trustHookItem } from "../app-server/catalog/data";
import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/threads/data";
import {
deleteArchivedThread as deleteArchivedThreadOnAppServer,
restoreArchivedThread as restoreArchivedThreadOnAppServer,
} from "../app-server/threads/data";
import type { AppServerSharedQueries } from "../app-server/query/shared-queries";
import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../domain/catalog/metadata";
@ -35,6 +38,10 @@ type SettingsAppServerData = Pick<
type SettingsThreadCatalog = Pick<SharedThreadCatalog, "invalidateThreadsFromOpenSurface">;
function archivedThreadTitleForStatus(thread: Thread | undefined, threadId: string): string {
return thread ? archivedThreadDisplayTitle(thread) : threadId;
}
interface SettingsDynamicDataControllerCallbacks {
display(): void;
notify(message: string): void;
@ -347,6 +354,41 @@ export class SettingsDynamicDataController {
}
}
async deleteArchivedThread(threadId: string): Promise<void> {
const operationId = this.nextSettingsDynamicOperationId();
const title = archivedThreadTitleForStatus(
this.archivedThreads.find((thread) => thread.id === threadId),
threadId,
);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "started",
status: "Loading archived threads...",
operationId,
});
this.callbacks.display();
try {
await this.withSettingsConnection((client) => deleteArchivedThreadOnAppServer(client, threadId));
if (this.isStaleSettingsDynamicOperation(operationId)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Deleted "${title}".`,
operationId,
});
this.host.threadCatalog.invalidateThreadsFromOpenSurface();
} catch (error) {
if (this.isStaleSettingsDynamicOperation(operationId)) return;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: `Could not delete archived thread: ${errorMessage(error)}`,
operationId,
});
this.callbacks.notify("Could not delete archived Codex thread.");
} finally {
if (!this.isStaleSettingsDynamicOperation(operationId)) this.callbacks.display();
}
}
modelMetadata(): ModelMetadata[] {
return sortedModelMetadata(this.models);
}

View file

@ -14,11 +14,14 @@ export interface ArchivedThreadSectionState {
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 {
@ -128,23 +131,44 @@ function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedTh
function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
text: "Restore archived threads to the active thread list.",
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(`Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`)
.addExtraButton((button) => {
.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");
}
}

View file

@ -18,6 +18,18 @@ function renderSettingsHeading(containerEl: HTMLElement, name: string): void {
export class CodexPanelSettingTab extends PluginSettingTab {
private readonly dynamicData: SettingsDynamicDataController;
private archivedDeleteConfirmThreadId: string | null = null;
private readonly cancelArchivedDeleteConfirmOnOutsidePointer = (event: PointerEvent): void => {
if (!this.archivedDeleteConfirmThreadId) return;
const target = event.target;
const viewWindow = this.containerEl.ownerDocument.defaultView;
if (viewWindow && target instanceof viewWindow.Element) {
const deleteConfirm = target.closest(".codex-panel-settings__archived-row--delete-confirming");
if (deleteConfirm && this.containerEl.contains(deleteConfirm)) return;
}
this.archivedDeleteConfirmThreadId = null;
this.renderSettingsTab({ autoLoadCodexData: false });
};
constructor(
app: App,
@ -41,6 +53,8 @@ export class CodexPanelSettingTab extends PluginSettingTab {
}
override hide(): void {
this.containerEl.removeEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
this.archivedDeleteConfirmThreadId = null;
this.dynamicData.dispose();
super.hide();
}
@ -49,6 +63,8 @@ export class CodexPanelSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass("codex-panel-settings");
containerEl.removeEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
containerEl.addEventListener("pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
this.renderHeaderActions(containerEl, SETTINGS_INTRO_TEXT);
this.renderPanelPreferenceSections(containerEl);
@ -214,11 +230,23 @@ export class CodexPanelSettingTab extends PluginSettingTab {
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) => void this.dynamicData.restoreArchivedThread(threadId),
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);
},
});
renderHookSection(containerEl, {

View file

@ -79,6 +79,10 @@
border-top: 0;
}
.codex-panel-settings__archived-row--delete-confirming {
background: color-mix(in srgb, var(--codex-panel-color-danger) 8%, transparent);
}
.codex-panel-settings__dynamic-row .setting-item-info {
min-width: 0;
}
@ -107,3 +111,8 @@
.codex-panel-settings__hook-error {
color: var(--codex-panel-color-danger);
}
.codex-panel-settings__archived-row--delete-confirming .setting-item-description,
.codex-panel-settings__archived-delete-confirm {
color: var(--codex-panel-color-danger);
}

View file

@ -483,7 +483,7 @@ describe("settings tab", () => {
tab.display();
await flushPromises();
expect(tab.containerEl.textContent).toContain("Restore archived threads to the active thread list.");
expect(tab.containerEl.textContent).toContain("Restore archived threads, or permanently delete archived threads you no longer need.");
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(
@ -497,6 +497,56 @@ describe("settings tab", () => {
expect(tab.containerEl.querySelector(".codex-panel-settings__hook-list")?.textContent).toContain("abc123");
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-list")?.textContent).toContain("Archived thread");
});
it("confirms archived thread deletion inline and cancels from outside clicks", async () => {
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();
tab.display();
await flushPromises();
clickButtonByLabel(tab, "Delete Archived thread");
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).not.toBeNull();
expect(tab.containerEl.textContent).toContain("Permanently delete this archived thread? This cannot be undone.");
expect(tab.containerEl.querySelector("[aria-label='Confirm permanent delete Archived thread']")?.getAttribute("data-icon")).toBe(
"check",
);
tab.containerEl.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
expect(tab.containerEl.querySelector(".codex-panel-settings__archived-row--delete-confirming")).toBeNull();
expect(tab.containerEl.textContent).not.toContain("Permanently delete this archived thread?");
expect(client.deleteThread).not.toHaveBeenCalled();
});
it("permanently deletes an archived thread from the confirmed settings row", async () => {
const invalidateThreadsFromOpenSurface = vi.fn();
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({ invalidateThreadsFromOpenSurface });
tab.display();
await flushPromises();
clickButtonByLabel(tab, "Delete Archived thread");
clickButtonByLabel(tab, "Confirm permanent delete Archived thread");
await flushPromises();
expect(client.deleteThread).toHaveBeenCalledWith("thread-archived");
expect(invalidateThreadsFromOpenSurface).toHaveBeenCalledOnce();
expect(tab.containerEl.textContent).toContain("No archived threads.");
expect(tab.containerEl.querySelectorAll(".codex-panel-settings__archived-list .setting-item")).toHaveLength(0);
});
});
function panelThread(overrides: Partial<Thread> = {}): Thread {
@ -598,6 +648,7 @@ function settingsClient(
});
}),
listThreads: vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }),
deleteThread: vi.fn().mockResolvedValue({}),
};
}