diff --git a/README.md b/README.md index aa80ad82..6279cfcf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index eab803ff..4c31c4ed 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -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 { + return this.request("thread/delete", { threadId }); + } + readThread(threadId: string, includeTurns = true): Promise { return this.request("thread/read", { threadId, includeTurns }); } diff --git a/src/app-server/threads/data.ts b/src/app-server/threads/data.ts index c300d59e..d09d2bc8 100644 --- a/src/app-server/threads/data.ts +++ b/src/app-server/threads/data.ts @@ -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 { + await client.deleteThread(threadId); +} diff --git a/src/settings/dynamic-data-controller.ts b/src/settings/dynamic-data-controller.ts index 16bbd73c..7d0ff4ea 100644 --- a/src/settings/dynamic-data-controller.ts +++ b/src/settings/dynamic-data-controller.ts @@ -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; +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 { + 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); } diff --git a/src/settings/dynamic-sections.ts b/src/settings/dynamic-sections.ts index 084c7622..eb1fe084 100644 --- a/src/settings/dynamic-sections.ts +++ b/src/settings/dynamic-sections.ts @@ -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"); } } diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 1add3b02..297a62b6 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -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, { diff --git a/src/styles/60-settings.css b/src/styles/60-settings.css index 5770551c..d50a8f73 100644 --- a/src/styles/60-settings.css +++ b/src/styles/60-settings.css @@ -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); +} diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index edb0e724..c4d2715f 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -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) => 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) => 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 { @@ -598,6 +648,7 @@ function settingsClient( }); }), listThreads: vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }), + deleteThread: vi.fn().mockResolvedValue({}), }; }