From 81cf4b79d5385c3db87eed315f2f2761d6f68b4a Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 26 May 2026 22:50:49 +0900 Subject: [PATCH] Add two-step archive actions in thread lists --- src/features/chat/slash-commands.ts | 2 +- src/features/chat/thread-actions.ts | 4 +- src/features/chat/ui/toolbar.ts | 77 ++++++++++++---- src/features/chat/view.ts | 36 +++++++- src/features/threads-view/renderer.ts | 61 +++++++++++-- src/features/threads-view/state.ts | 7 ++ src/features/threads-view/view.ts | 42 ++++++++- src/settings/dynamic-sections.ts | 26 +++--- styles.css | 27 ++++++ tests/features/chat/ui/view-renderers.test.ts | 88 +++++++++++++++++++ tests/features/threads-view/view.test.ts | 1 + tests/settings/settings-tab.test.ts | 17 ++-- 12 files changed, 335 insertions(+), 53 deletions(-) diff --git a/src/features/chat/slash-commands.ts b/src/features/chat/slash-commands.ts index 6bbf2bed..605a8678 100644 --- a/src/features/chat/slash-commands.ts +++ b/src/features/chat/slash-commands.ts @@ -22,7 +22,7 @@ export interface SlashCommandExecutionContext { forkThread: (threadId: string) => Promise; rollbackThread: (threadId: string) => Promise; compactThread: (threadId: string) => Promise; - archiveThread: (threadId: string) => Promise; + archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise; toggleFastMode: () => void | Promise; toggleCollaborationMode: () => void | Promise; toggleAutoReview: () => void | Promise; diff --git a/src/features/chat/thread-actions.ts b/src/features/chat/thread-actions.ts index 85a0f50e..6eb52295 100644 --- a/src/features/chat/thread-actions.ts +++ b/src/features/chat/thread-actions.ts @@ -31,7 +31,7 @@ export interface ChatThreadActionControllerHost { export class ChatThreadActionController { constructor(private readonly host: ChatThreadActionControllerHost) {} - async archiveThread(threadId: string): Promise { + async archiveThread(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise { if (this.host.state.busy) { this.host.addSystemMessage("Finish or interrupt the current turn before archiving threads."); return; @@ -40,7 +40,7 @@ export class ChatThreadActionController { if (!client) return; try { const settings = this.host.settings(); - if (settings.archiveExportEnabled) { + if (saveMarkdown) { const response = await client.readThread(threadId, true); const result = await exportArchivedThreadMarkdown(response.thread, settings, this.host.archiveAdapter()); new Notice(`Saved archived thread to ${result.path}.`); diff --git a/src/features/chat/ui/toolbar.ts b/src/features/chat/ui/toolbar.ts index bf578bf5..7bc2f285 100644 --- a/src/features/chat/ui/toolbar.ts +++ b/src/features/chat/ui/toolbar.ts @@ -22,6 +22,7 @@ export interface ToolbarThreadRow { selected: boolean; disabled: boolean; canArchive: boolean; + archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean }; rename: { draft: string; generating: boolean; @@ -75,7 +76,8 @@ export interface ToolbarActions { refreshDiagnostics: () => void; refreshThreads: () => void; resumeThread: (threadId: string) => void; - archiveThread: (threadId: string) => void; + startArchiveThread: (threadId: string) => void; + archiveThread: (threadId: string, saveMarkdown: boolean) => void; startRenameThread: (threadId: string) => void; updateRenameDraft: (threadId: string, value: string) => void; saveRenameThread: (threadId: string, value: string) => void; @@ -107,7 +109,7 @@ export function toolbarSignature(model: ToolbarViewModel): string { (thread) => `${thread.threadId}:${thread.title}:${String(thread.selected)}:${String(thread.disabled)}:${String(thread.canArchive)}:${thread.rename?.draft ?? ""}:${String( thread.rename?.generating ?? false, - )}`, + )}:${String(thread.archiveConfirm?.active ?? false)}:${String(thread.archiveConfirm?.defaultSaveMarkdown ?? false)}`, ), modelChoices: model.modelChoices.map( (choice) => `${choice.label}:${String(choice.selected)}:${String(choice.disabled)}:${choice.meta ?? ""}`, @@ -326,7 +328,13 @@ function renderThreadList(parent: HTMLElement, threads: ToolbarThreadRow[], acti for (const thread of threads) { const row = threadsEl.createDiv({ - cls: ["codex-panel__thread-row", thread.rename ? "codex-panel__thread-row--renaming" : ""].filter(Boolean).join(" "), + cls: [ + "codex-panel__thread-row", + thread.rename ? "codex-panel__thread-row--renaming" : "", + archiveConfirmState(thread).active ? "codex-panel__thread-row--archive-confirming" : "", + ] + .filter(Boolean) + .join(" "), }); if (thread.rename) { renderThreadRenameRow(row, thread, actions); @@ -342,26 +350,61 @@ function renderThreadList(parent: HTMLElement, threads: ToolbarThreadRow[], acti }, }); - const rename = createToolbarButton(row, "pencil", "Rename thread"); - rename.addClass("codex-panel__thread-action"); - rename.disabled = thread.disabled; - rename.onclick = (event) => { - event.stopPropagation(); - actions.startRenameThread(thread.threadId); - }; - - if (thread.canArchive) { - const action = createToolbarButton(row, "archive", "Archive thread"); - action.addClass("codex-panel__thread-action"); - action.disabled = thread.disabled; - action.onclick = (event) => { + if (!archiveConfirmState(thread).active) { + const rename = createToolbarButton(row, "pencil", "Rename thread"); + rename.addClass("codex-panel__thread-action"); + rename.disabled = thread.disabled; + rename.onclick = (event) => { event.stopPropagation(); - actions.archiveThread(thread.threadId); + actions.startRenameThread(thread.threadId); }; } + + if (thread.canArchive) renderArchiveActions(row, thread, actions); } } +function renderArchiveActions(parent: HTMLElement, thread: ToolbarThreadRow, actions: ToolbarActions): void { + const archiveConfirm = archiveConfirmState(thread); + if (!archiveConfirm.active) { + const action = createToolbarButton(parent, "archive", "Archive thread"); + action.addClass("codex-panel__thread-action"); + action.disabled = thread.disabled; + action.onclick = (event) => { + event.stopPropagation(); + actions.startArchiveThread(thread.threadId); + }; + return; + } + + parent.addClass("codex-panel__archive-confirm"); + const defaultSaveMarkdown = archiveConfirm.defaultSaveMarkdown; + const alternate = archiveModeButton(parent, !defaultSaveMarkdown, false); + alternate.disabled = thread.disabled; + alternate.onclick = (event) => { + event.stopPropagation(); + actions.archiveThread(thread.threadId, !defaultSaveMarkdown); + }; + const primary = archiveModeButton(parent, defaultSaveMarkdown, true); + primary.disabled = thread.disabled; + primary.onclick = (event) => { + event.stopPropagation(); + actions.archiveThread(thread.threadId, defaultSaveMarkdown); + }; +} + +function archiveConfirmState(thread: ToolbarThreadRow): { active: boolean; defaultSaveMarkdown: boolean } { + return thread.archiveConfirm ?? { active: false, defaultSaveMarkdown: false }; +} + +function archiveModeButton(parent: HTMLElement, saveMarkdown: boolean, primary: boolean): HTMLButtonElement { + const label = saveMarkdown ? "Save and archive thread" : "Archive thread without saving"; + const button = createToolbarButton(parent, saveMarkdown ? "save" : "trash", label); + button.addClass("codex-panel__thread-action"); + button.addClass(primary ? "codex-panel__archive-default" : "codex-panel__archive-alternate"); + return button; +} + function renderThreadRenameRow(parent: HTMLElement, thread: ToolbarThreadRow, actions: ToolbarActions): void { let input!: HTMLInputElement; createToolbarPanelRow(parent, thread.title, { diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 9db5876a..609a0075 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -117,6 +117,7 @@ export class CodexChatView extends ItemView { private scheduledRestoredThreadHydrationTimer: number | null = null; private scheduledRenderTimer: number | null = null; private scheduledDiagnosticsTimer: number | null = null; + private archiveConfirmThreadId: string | null = null; private connectingPromise: Promise | null = null; private connectionGeneration = 0; private resumeGeneration = 0; @@ -1238,7 +1239,10 @@ export class CodexChatView extends ItemView { this.state.openDetails.delete("history"); void this.selectThread(threadId); }, - archiveThread: (threadId) => void this.threadActions.archiveThread(threadId), + startArchiveThread: (threadId) => { + this.startArchiveThread(threadId); + }, + archiveThread: (threadId, saveMarkdown) => void this.archiveThread(threadId, saveMarkdown), startRenameThread: (threadId) => { this.threadRename.start(threadId); }, @@ -1290,6 +1294,10 @@ export class CodexChatView extends ItemView { selected: threadId === this.state.activeThreadId, disabled: this.state.busy && threadId !== this.state.activeThreadId, canArchive: true, + archiveConfirm: { + active: this.archiveConfirmThreadId === threadId, + defaultSaveMarkdown: this.plugin.settings.archiveExportEnabled, + }, rename: this.threadRename.editState(threadId), }; }), @@ -1376,10 +1384,22 @@ export class CodexChatView extends ItemView { this.addSystemMessage("Finish or interrupt the current turn before switching threads."); return; } + this.archiveConfirmThreadId = null; if (await this.plugin.focusThreadInOpenView(threadId)) return; await this.resumeThread(threadId); } + private startArchiveThread(threadId: string): void { + this.archiveConfirmThreadId = threadId; + this.scheduleRender(); + } + + private async archiveThread(threadId: string, saveMarkdown: boolean): Promise { + if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null; + await this.threadActions.archiveThread(threadId, saveMarkdown); + this.scheduleRender(); + } + private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void { if (!this.hasOpenToolbarPanel()) return; @@ -1387,7 +1407,18 @@ export class CodexChatView extends ItemView { const viewWindow = this.containerEl.doc.defaultView; if (viewWindow && target instanceof viewWindow.Element) { const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel"); - if (insideToolbarPanel && this.containerEl.contains(insideToolbarPanel)) return; + if (insideToolbarPanel && this.containerEl.contains(insideToolbarPanel)) { + if (this.archiveConfirmThreadId && !target.closest(".codex-panel__archive-confirm")) { + this.archiveConfirmThreadId = null; + this.scheduleRender(); + } + return; + } + } + + if (this.archiveConfirmThreadId) { + this.archiveConfirmThreadId = null; + this.scheduleRender(); } if (this.threadRename.isEditing()) return; @@ -1405,6 +1436,7 @@ export class CodexChatView extends ItemView { this.state.openDetails.delete("history"); this.state.openDetails.delete("status-panel"); this.state.runtimePicker = null; + this.archiveConfirmThreadId = null; this.scheduleRender(); } diff --git a/src/features/threads-view/renderer.ts b/src/features/threads-view/renderer.ts index f698314e..24a84eee 100644 --- a/src/features/threads-view/renderer.ts +++ b/src/features/threads-view/renderer.ts @@ -17,7 +17,8 @@ export interface ThreadsViewActions { saveRename: (threadId: string, value: string) => void; cancelRename: (threadId: string) => void; autoNameThread: (threadId: string) => void; - archiveThread: (threadId: string) => void; + startArchive: (threadId: string) => void; + archiveThread: (threadId: string, saveMarkdown: boolean) => void; } export function renderThreadsView(parent: HTMLElement, model: ThreadsViewModel, actions: ThreadsViewActions): void { @@ -57,11 +58,13 @@ function renderThreadRow(parent: HTMLElement, row: ThreadsRowModel, actions: Thr }); if (row.live) item.addClass(`codex-panel-threads__row--${row.live.status}`); if (row.rename.active) item.addClass("codex-panel-threads__row--renaming"); + if (archiveConfirmState(row).active) item.addClass("codex-panel-threads__row--archive-confirming"); item.onclick = () => { + if (archiveConfirmState(row).active) return; actions.openThread(row.thread.id); }; item.onkeydown = (event) => { - if (row.rename.active) return; + if (row.rename.active || archiveConfirmState(row).active) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); actions.openThread(row.thread.id); @@ -76,18 +79,60 @@ function renderThreadRow(parent: HTMLElement, row: ThreadsRowModel, actions: Thr titleLine.createSpan({ cls: "codex-panel-threads__row-title", text: row.title }); const controls = item.createDiv({ cls: "codex-panel-threads__actions" }); - const rename = iconButton(controls, "pencil", "Rename thread", "codex-panel-threads__row-button"); - rename.onclick = (event) => { + if (!archiveConfirmState(row).active) { + const rename = iconButton(controls, "pencil", "Rename thread", "codex-panel-threads__row-button"); + rename.onclick = (event) => { + event.stopPropagation(); + actions.startRename(row.thread.id, row.thread.name ?? row.title); + }; + } + renderArchiveActions(controls, row, actions); +} + +function renderArchiveActions(parent: HTMLElement, row: ThreadsRowModel, actions: ThreadsViewActions): void { + const archiveConfirm = archiveConfirmState(row); + if (!archiveConfirm.active) { + const archive = iconButton(parent, "archive", "Archive thread", "codex-panel-threads__row-button"); + archive.onclick = (event) => { + event.stopPropagation(); + actions.startArchive(row.thread.id); + }; + return; + } + + parent.addClass("codex-panel-threads__archive-confirm"); + const defaultSaveMarkdown = archiveConfirm.defaultSaveMarkdown; + const alternate = archiveModeButton(parent, !defaultSaveMarkdown, false); + alternate.onclick = (event) => { event.stopPropagation(); - actions.startRename(row.thread.id, row.thread.name ?? row.title); + actions.archiveThread(row.thread.id, !defaultSaveMarkdown); }; - const archive = iconButton(controls, "archive", "Archive thread", "codex-panel-threads__row-button"); - archive.onclick = (event) => { + const primary = archiveModeButton(parent, defaultSaveMarkdown, true); + primary.onclick = (event) => { event.stopPropagation(); - actions.archiveThread(row.thread.id); + actions.archiveThread(row.thread.id, defaultSaveMarkdown); }; } +function archiveConfirmState(row: ThreadsRowModel): { active: boolean; defaultSaveMarkdown: boolean } { + return row.archiveConfirm ?? { active: false, defaultSaveMarkdown: false }; +} + +function archiveModeButton(parent: HTMLElement, saveMarkdown: boolean, primary: boolean): HTMLButtonElement { + const label = saveMarkdown ? "Save and archive thread" : "Archive thread without saving"; + const icon = saveMarkdown ? "save" : "trash"; + const button = iconButton( + parent, + icon, + label, + ["codex-panel-threads__row-button", primary ? "codex-panel-threads__archive-default" : "codex-panel-threads__archive-alternate"] + .filter(Boolean) + .join(" "), + ); + button.setAttr("aria-label", label); + return button; +} + function renderRenameRow(parent: HTMLElement, row: ThreadsRowModel, actions: ThreadsViewActions): void { parent.onclick = (event) => { event.stopPropagation(); diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index 934d6070..d63321c5 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -16,6 +16,7 @@ export interface ThreadsRowModel { title: string; live: ThreadsLiveState | null; rename: { active: boolean; draft: string; generating: boolean }; + archiveConfirm?: { active: boolean; defaultSaveMarkdown: boolean }; } const STATUS_PRIORITY: Record = { @@ -32,6 +33,8 @@ export function threadRows( snapshots: OpenCodexPanelSnapshot[], renameDrafts: ReadonlyMap, autoNameThreadId: string | null = null, + archiveConfirmThreadId: string | null = null, + defaultArchiveSaveMarkdown = false, ): ThreadsRowModel[] { const snapshotsByThread = snapshotsForThreads(snapshots); return [...threads] @@ -47,6 +50,10 @@ export function threadRows( draft: renameDrafts.get(thread.id) ?? thread.name ?? getThreadTitle(thread), generating: autoNameThreadId === thread.id, }, + archiveConfirm: { + active: archiveConfirmThreadId === thread.id, + defaultSaveMarkdown: defaultArchiveSaveMarkdown, + }, }; }); } diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index 810f6860..611f683a 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -38,6 +38,7 @@ export class CodexThreadsView extends ItemView { private readonly renameDrafts = new Map(); private renameAutoNameThreadId: string | null = null; private renameAutoNameGeneration = 0; + private archiveConfirmThreadId: string | null = null; constructor( leaf: WorkspaceLeaf, @@ -77,6 +78,9 @@ export class CodexThreadsView extends ItemView { } override async onOpen(): Promise { + this.registerDomEvent(this.containerEl.doc, "pointerdown", (event) => { + this.cancelArchiveConfirmOnOutsidePointer(event); + }); const cachedThreads = this.plugin.cachedThreadList(); if (cachedThreads) { this.threads = cachedThreads; @@ -160,7 +164,14 @@ export class CodexThreadsView extends ItemView { { status: this.status, loading: this.loading, - rows: threadRows(this.threads, this.plugin.getOpenPanelSnapshots(), this.renameDrafts, this.renameAutoNameThreadId), + rows: threadRows( + this.threads, + this.plugin.getOpenPanelSnapshots(), + this.renameDrafts, + this.renameAutoNameThreadId, + this.archiveConfirmThreadId, + this.plugin.settings.archiveExportEnabled, + ), }, { refresh: () => void this.refresh(), @@ -177,7 +188,10 @@ export class CodexThreadsView extends ItemView { this.cancelRename(threadId); }, autoNameThread: (threadId) => void this.autoNameThread(threadId), - archiveThread: (threadId) => void this.archiveThread(threadId), + startArchive: (threadId) => { + this.startArchive(threadId); + }, + archiveThread: (threadId, saveMarkdown) => void this.archiveThread(threadId, saveMarkdown), }, ); } @@ -209,6 +223,7 @@ export class CodexThreadsView extends ItemView { } private async openThread(threadId: string): Promise { + this.archiveConfirmThreadId = null; await this.plugin.openThreadInAvailableView(threadId); } @@ -217,6 +232,7 @@ export class CodexThreadsView extends ItemView { } private startRename(threadId: string, value: string): void { + this.archiveConfirmThreadId = null; this.renameAutoNameGeneration += 1; this.renameAutoNameThreadId = null; this.renameDrafts.set(threadId, value); @@ -291,16 +307,34 @@ export class CodexThreadsView extends ItemView { } } - private async archiveThread(threadId: string): Promise { + private startArchive(threadId: string): void { + this.archiveConfirmThreadId = threadId; + this.render(); + } + + private cancelArchiveConfirmOnOutsidePointer(event: PointerEvent): void { + if (!this.archiveConfirmThreadId) return; + const target = event.target; + const viewWindow = this.containerEl.doc.defaultView; + if (viewWindow && target instanceof viewWindow.Element) { + const archiveConfirm = target.closest(".codex-panel-threads__archive-confirm"); + if (archiveConfirm && this.containerEl.contains(archiveConfirm)) return; + } + this.archiveConfirmThreadId = null; + this.render(); + } + + private async archiveThread(threadId: string, saveMarkdown: boolean): Promise { try { await this.ensureConnected(); if (!this.client) return; - if (this.plugin.settings.archiveExportEnabled) { + if (saveMarkdown) { const response = await this.client.readThread(threadId, true); const result = await exportArchivedThreadMarkdown(response.thread, this.plugin.settings, this.app.vault.adapter); new Notice(`Saved archived thread to ${result.path}.`); } await this.client.archiveThread(threadId); + if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null; this.renameDrafts.delete(threadId); this.plugin.notifyThreadArchived(threadId); } catch (error) { diff --git a/src/settings/dynamic-sections.ts b/src/settings/dynamic-sections.ts index 8b8c634b..8c7ed4b9 100644 --- a/src/settings/dynamic-sections.ts +++ b/src/settings/dynamic-sections.ts @@ -56,8 +56,10 @@ export function renderArchivedThreadSection(containerEl: HTMLElement, state: Arc new Setting(section) .setClass("codex-panel-settings__dynamic-section-heading") .setHeading() - .setName("Archive actions") - .setDesc("Save threads to notes before archiving, or restore archived threads."); + .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); @@ -83,9 +85,9 @@ export function renderArchivedThreadSection(containerEl: HTMLElement, state: Arc function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedThreadSectionState): void { new Setting(containerEl) - .setName("Save before archiving") + .setName("Save note by default") .setDesc( - "Save a markdown note before archiving. If saving fails, the thread stays active. Frontmatter includes title, thread_id, created, and optional tags.", + "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) => { @@ -94,7 +96,7 @@ function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedTh }); new Setting(containerEl) - .setName("Save folder") + .setName("Saved note folder") .setDesc("Vault-relative folder for saved thread notes. The folder is created when needed.") .addText((text) => { text @@ -106,7 +108,7 @@ function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedTh }); new Setting(containerEl) - .setName("Save filename") + .setName("Saved note filename") .setDesc("Filename template. Variables: {{date}}, {{time}}, {{title}}, {{id}}, {{shortId}}. Existing files get a numeric suffix.") .addText((text) => { text @@ -118,7 +120,7 @@ function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedTh }); new Setting(containerEl) - .setName("Save tags") + .setName("Saved note tags") .setDesc("Comma-separated fixed tags for saved notes. Leave empty to omit tags.") .addText((text) => { text @@ -131,11 +133,13 @@ function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedTh } function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThreadSectionState): void { - containerEl.createEl("p", { + const summary = containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-list-summary", - text: `Restore archived threads to the thread list. Loaded ${String(state.threads.length)} archived thread${ - state.threads.length === 1 ? "" : "s" - } from Codex app server.`, + }); + 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.`, }); const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" }); for (const thread of state.threads) { diff --git a/styles.css b/styles.css index 35b4c5c2..ad2ee8aa 100644 --- a/styles.css +++ b/styles.css @@ -294,6 +294,10 @@ opacity: 1; } +.codex-panel-threads__row--archive-confirming .codex-panel-threads__actions { + opacity: 1; +} + .codex-panel-threads__rename-input.codex-panel-threads__rename-input { position: relative; appearance: none; @@ -627,11 +631,25 @@ padding: 0 var(--size-4-4); } +.codex-panel-settings__section-intro, +.codex-panel-settings__dynamic-list-summary { + color: var(--text-muted); + font-size: var(--font-ui-small); + line-height: var(--line-height-normal); +} + .codex-panel-settings__dynamic-list-summary { padding: 0 var(--size-4-4); 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; @@ -1152,6 +1170,10 @@ grid-template-columns: minmax(0, 1fr) auto; } +.codex-panel__thread-row--archive-confirming { + grid-template-columns: minmax(0, 1fr) auto auto; +} + .codex-panel__thread:hover:not(:where(:disabled, .is-disabled)), .codex-panel__thread.is-checked { background: transparent; @@ -1242,6 +1264,11 @@ opacity: 0.4; } +.codex-panel__thread-row--archive-confirming .codex-panel__thread-action { + visibility: visible; + opacity: 1; +} + .codex-panel__thread-action:hover, .codex-panel__thread-action:focus, .codex-panel__thread-action:focus-visible, diff --git a/tests/features/chat/ui/view-renderers.test.ts b/tests/features/chat/ui/view-renderers.test.ts index 7c801c84..1e6b1a6d 100644 --- a/tests/features/chat/ui/view-renderers.test.ts +++ b/tests/features/chat/ui/view-renderers.test.ts @@ -116,6 +116,7 @@ function threadsViewActions() { saveRename: vi.fn(), cancelRename: vi.fn(), autoNameThread: vi.fn(), + startArchive: vi.fn(), archiveThread: vi.fn(), }; } @@ -1908,6 +1909,47 @@ describe("toolbar renderer decisions", () => { expect(parent.querySelector('[aria-label="Auto-name thread"]')?.disabled).toBe(true); expect(parent.querySelector('[aria-label="Cancel rename"]')).toBeNull(); }); + + it("renders toolbar archive confirmation with the default action on the right", () => { + const parent = document.createElement("div"); + const startArchiveThread = vi.fn(); + const archiveThread = vi.fn(); + + renderToolbar( + parent, + toolbarModel({ + historyOpen: true, + openPanel: "history", + threads: [ + { + title: "Thread", + threadId: "thread", + selected: true, + disabled: false, + canArchive: true, + archiveConfirm: { active: true, defaultSaveMarkdown: true }, + rename: null, + }, + ], + }), + toolbarActions({ startArchiveThread, archiveThread }), + ); + + const confirm = expectPresent(parent.querySelector(".codex-panel__archive-confirm")); + const archiveButtons = [ + ...confirm.querySelectorAll(".codex-panel__archive-alternate, .codex-panel__archive-default"), + ]; + expect(archiveButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Archive thread without saving", + "Save and archive thread", + ]); + expect(parent.querySelector('[aria-label="Rename thread"]')).toBeNull(); + archiveButtons[0]?.click(); + expect(archiveThread).toHaveBeenCalledWith("thread", false); + archiveButtons[1]?.click(); + expect(archiveThread).toHaveBeenCalledWith("thread", true); + expect(startArchiveThread).not.toHaveBeenCalled(); + }); }); describe("threads view renderer decisions", () => { @@ -1965,6 +2007,51 @@ describe("threads view renderer decisions", () => { expect(parent.querySelector('[aria-label="Open in new panel"]')).toBeNull(); }); + it("renders threads view archive confirmation with the default action on the right", () => { + const parent = document.createElement("div"); + const actions = threadsViewActions(); + const row: ThreadsRowModel = { + thread: threadFixture({ id: "thread", name: "Thread" }), + title: "Thread", + live: null, + rename: { active: false, draft: "Thread", generating: false }, + archiveConfirm: { active: true, defaultSaveMarkdown: false }, + }; + + renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + + const confirm = expectPresent(parent.querySelector(".codex-panel-threads__archive-confirm")); + const archiveButtons = [ + ...confirm.querySelectorAll(".codex-panel-threads__archive-alternate, .codex-panel-threads__archive-default"), + ]; + expect(archiveButtons.map((button) => button.getAttribute("aria-label"))).toEqual([ + "Save and archive thread", + "Archive thread without saving", + ]); + expect(parent.querySelector('[aria-label="Rename thread"]')).toBeNull(); + archiveButtons[0]?.click(); + expect(actions.archiveThread).toHaveBeenCalledWith("thread", true); + archiveButtons[1]?.click(); + expect(actions.archiveThread).toHaveBeenCalledWith("thread", false); + }); + + it("starts threads view archive confirmation before archiving", () => { + const parent = document.createElement("div"); + const actions = threadsViewActions(); + const row: ThreadsRowModel = { + thread: threadFixture({ id: "thread", name: "Thread" }), + title: "Thread", + live: null, + rename: { active: false, draft: "Thread", generating: false }, + }; + + renderThreadsView(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + parent.querySelector('[aria-label="Archive thread"]')?.click(); + + expect(actions.startArchive).toHaveBeenCalledWith("thread"); + expect(actions.archiveThread).not.toHaveBeenCalled(); + }); + it("renders rename rows and saves entered values", () => { const parent = document.createElement("div"); const actions = threadsViewActions(); @@ -2457,6 +2544,7 @@ function toolbarActions(overrides: Partial[2]> refreshDiagnostics: vi.fn(), refreshThreads: vi.fn(), resumeThread: vi.fn(), + startArchiveThread: vi.fn(), archiveThread: vi.fn(), startRenameThread: vi.fn(), updateRenameDraft: vi.fn(), diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index c1395f0f..d2837376 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -248,6 +248,7 @@ describe("CodexThreadsView", () => { await view.refresh(); view.containerEl.querySelector('[aria-label="Archive thread"]')?.click(); + view.containerEl.querySelector('[aria-label="Archive thread without saving"]')?.click(); await vi.waitFor(() => { expect(archiveThread).toHaveBeenCalledWith("thread"); diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index d17456b4..523e20bd 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -98,7 +98,7 @@ describe("settings tab", () => { "Codex helpers", "Automatic thread naming", "Selection rewrite", - "Archive actions", + "Thread archiving", "Hook status", ]); }); @@ -126,9 +126,9 @@ describe("settings tab", () => { tab.display(); const toggle = tab.containerEl.querySelector('input[type="checkbox"]'); - const folder = inputForSetting(tab, "Save folder"); - const filename = inputForSetting(tab, "Save filename"); - const tags = inputForSetting(tab, "Save tags"); + const folder = inputForSetting(tab, "Saved note folder"); + const filename = inputForSetting(tab, "Saved note filename"); + const tags = inputForSetting(tab, "Saved note tags"); if (!toggle || !folder || !filename || !tags) throw new Error("Missing archive export controls"); toggle.checked = true; @@ -143,8 +143,9 @@ describe("settings tab", () => { expect(saveSettings).toHaveBeenCalledTimes(4); expect(tab.containerEl.textContent).toContain("title, thread_id, created, and optional tags"); - expect(settingDesc(tab, "Save before archiving")).toContain("If saving fails"); - expect(settingDesc(tab, "Save tags")).toContain("Leave empty to omit tags"); + expect(settingDesc(tab, "Save note by default")).toContain("default archive action"); + expect(settingDesc(tab, "Save note by default")).toContain("If saving fails"); + expect(settingDesc(tab, "Saved note tags")).toContain("Leave empty to omit tags"); }); it("refreshes models, hooks, and archived threads from the global refresh button", async () => { @@ -225,13 +226,13 @@ describe("settings tab", () => { await flushPromises(); expect(tab.containerEl.textContent).toContain("Loaded 1 hook from Codex app server."); - expect(tab.containerEl.textContent).toContain("Restore archived threads to the thread list."); + 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.querySelector(".codex-panel-settings__hook-section .setting-item-heading")?.textContent).toContain( "Hook status", ); expect(tab.containerEl.querySelector(".codex-panel-settings__archived-section .setting-item-heading")?.textContent).toContain( - "Archive actions", + "Thread archiving", ); expect(tab.containerEl.querySelectorAll(".codex-panel-settings__hook-list .setting-item")).toHaveLength(1); expect(tab.containerEl.querySelectorAll(".codex-panel-settings__archived-list .setting-item")).toHaveLength(1);