mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Delete archived threads from settings
This commit is contained in:
parent
e59a782705
commit
6a5fcc67df
8 changed files with 171 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue