Add two-step archive actions in thread lists

This commit is contained in:
murashit 2026-05-26 22:50:49 +09:00
parent b3cb68ffbc
commit 81cf4b79d5
12 changed files with 335 additions and 53 deletions

View file

@ -22,7 +22,7 @@ export interface SlashCommandExecutionContext {
forkThread: (threadId: string) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
toggleFastMode: () => void | Promise<void>;
toggleCollaborationMode: () => void | Promise<void>;
toggleAutoReview: () => void | Promise<void>;

View file

@ -31,7 +31,7 @@ export interface ChatThreadActionControllerHost {
export class ChatThreadActionController {
constructor(private readonly host: ChatThreadActionControllerHost) {}
async archiveThread(threadId: string): Promise<void> {
async archiveThread(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise<void> {
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}.`);

View file

@ -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, {

View file

@ -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<void> | 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<void> {
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();
}

View file

@ -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();

View file

@ -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<ThreadsLiveStatus, number> = {
@ -32,6 +33,8 @@ export function threadRows(
snapshots: OpenCodexPanelSnapshot[],
renameDrafts: ReadonlyMap<string, string>,
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,
},
};
});
}

View file

@ -38,6 +38,7 @@ export class CodexThreadsView extends ItemView {
private readonly renameDrafts = new Map<string, string>();
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<void> {
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<void> {
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<void> {
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<void> {
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) {

View file

@ -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) {

View file

@ -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,

View file

@ -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<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.disabled).toBe(true);
expect(parent.querySelector<HTMLButtonElement>('[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<HTMLElement>(".codex-panel__archive-confirm"));
const archiveButtons = [
...confirm.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLElement>(".codex-panel-threads__archive-confirm"));
const archiveButtons = [
...confirm.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<Parameters<typeof renderToolbar>[2]>
refreshDiagnostics: vi.fn(),
refreshThreads: vi.fn(),
resumeThread: vi.fn(),
startArchiveThread: vi.fn(),
archiveThread: vi.fn(),
startRenameThread: vi.fn(),
updateRenameDraft: vi.fn(),

View file

@ -248,6 +248,7 @@ describe("CodexThreadsView", () => {
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Archive thread"]')?.click();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Archive thread without saving"]')?.click();
await vi.waitFor(() => {
expect(archiveThread).toHaveBeenCalledWith("thread");

View file

@ -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<HTMLInputElement>('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);