Add archive slash command

This commit is contained in:
murashit 2026-05-20 18:02:13 +09:00
parent 3abe494149
commit 275ce96405
8 changed files with 92 additions and 4 deletions

View file

@ -29,5 +29,5 @@ Use this skill for Codex Panel release work. `docs/release.md` is the public pro
## Failure Handling
- If `release:prepare`, `release:check`, or `release:preflight` fails, fix the cause and rerun the explicit command.
- If the tag-triggered workflow fails before GitHub Release creation, fix the release commit, move the local tag with `git tag -f X.Y.Z`, and force-update the remote tag.
- If the tag-triggered workflow fails before GitHub Release creation, fix the release commit, move the local tag with `jj tag set --allow-move -r main X.Y.Z`, and force-update the remote tag.
- If a GitHub Release already exists or was partially created, inspect the state before taking action; do not assume local asset upload is the right recovery path.

View file

@ -40,7 +40,7 @@ Use `Codex Panel: New chat` to start a fresh thread. Use `Codex Panel: Open new
Codex Panel supports the app-server-backed Codex workflows that fit a persistent side panel:
- Start, resume, rename, fork, roll back, compact, and archive threads (`/new`, `/resume`, `/fork`, `/rollback`, `/compact`).
- Start, resume, rename, fork, roll back, compact, and archive threads (`/new`, `/resume`, `/fork`, `/rollback`, `/compact`, `/archive`).
- Complete slash commands (`/help`) and enabled Codex skills from the composer.
- Reference another non-archived thread without switching away from the current one (`/refer <thread> <message>`). Codex receives up to 20 recent turns, limited to user input and final Codex responses.
- Toggle Plan mode, optionally sending the same message after switching (`/plan <message>`), then answer questions and copy or implement proposed plans.

View file

@ -5,6 +5,7 @@ export const SLASH_COMMANDS = [
{ command: "/fork", detail: "Fork the active Codex thread." },
{ command: "/rollback", detail: "Drop the latest turn and restore its prompt to the composer." },
{ command: "/compact", detail: "Compact the current conversation context." },
{ command: "/archive", detail: "Archive the active or selected Codex thread." },
{ command: "/fast", detail: "Toggle fast service tier for subsequent turns." },
{ command: "/plan", detail: "Toggle Plan mode, optionally sending a message." },
{ command: "/status", detail: "Show current session, context, and usage limits." },

View file

@ -146,7 +146,7 @@ export function activeSlashCommandSuggestions(beforeCursor: string): ComposerSug
}
export function activeThreadCommandSuggestions(beforeCursor: string, threads: Thread[]): ComposerSuggestion[] | null {
const match = beforeCursor.match(/(?:^|\n)\/(?:resume|refer)\s+([^\s\n]{0,120})$/);
const match = beforeCursor.match(/(?:^|\n)\/(?:resume|refer|archive)\s+([^\s\n]{0,120})$/);
if (!match || match.index === undefined) return null;
const rawQuery = match[1] ?? "";

View file

@ -21,6 +21,7 @@ export interface SlashCommandExecutionContext {
forkThread: (threadId: string) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string) => Promise<void>;
toggleFastMode: () => void;
toggleCollaborationMode: () => void;
addSystemMessage: (text: string) => void;
@ -131,6 +132,29 @@ export async function executeSlashCommand(
return;
}
if (command === "archive") {
if (context.busy) {
context.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
return;
}
if (!args) {
if (!context.activeThreadId) {
context.addSystemMessage("No active thread to archive.");
return;
}
await context.archiveThread(context.activeThreadId);
return;
}
const thread = resolveThreadArgument(args, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
await context.archiveThread(thread.thread.id);
return;
}
if (command === "fast") {
context.toggleFastMode();
return;

View file

@ -544,6 +544,7 @@ export class CodexPanelView extends ItemView {
compactThread: async (threadId) => {
await this.client?.compactThread(threadId);
},
archiveThread: (threadId) => this.archiveThread(threadId),
busy: this.state.busy,
toggleFastMode: () => this.toggleFastMode(),
toggleCollaborationMode: () => this.toggleCollaborationMode(),

View file

@ -72,6 +72,7 @@ describe("composer suggestions", () => {
expect(parseSlashCommand("/resume thread-1")).toEqual({ command: "resume", args: "thread-1" });
expect(parseSlashCommand("/refer thread-1 続きです")).toEqual({ command: "refer", args: "thread-1 続きです" });
expect(parseSlashCommand("/fork")).toEqual({ command: "fork", args: "" });
expect(parseSlashCommand("/archive thread-1")).toEqual({ command: "archive", args: "thread-1" });
expect(parseSlashCommand("/doctor")).toEqual({ command: "doctor", args: "" });
expect(parseSlashCommand("/fast now")).toEqual({ command: "fast", args: "now" });
expect(parseSlashCommand("/plan")).toEqual({ command: "plan", args: "" });
@ -100,7 +101,7 @@ describe("composer suggestions", () => {
expect(activeComposerSuggestions("/help", notes, [])).toEqual([]);
});
it("suggests recent threads for /resume and /refer arguments", () => {
it("suggests recent threads for /resume, /refer, and /archive arguments", () => {
const threads = [
thread({ id: "019abcde-0000-7000-8000-000000000001", name: "Codex Panel実装" }),
thread({ id: "019abcde-0000-7000-8000-000000000002", name: "別件" }),
@ -129,6 +130,13 @@ describe("composer suggestions", () => {
appendSpaceOnInsert: true,
});
expect(activeComposerSuggestions("/refer 019abcde-0000-7000-8000-000000000001 ", notes, [], threads)).toEqual([]);
expect(activeComposerSuggestions("/archive codex", notes, [], threads)[0]).toMatchObject({
display: "Codex Panel実装",
detail: "019abcde",
replacement: "019abcde-0000-7000-8000-000000000001",
appendSpaceOnInsert: true,
});
expect(activeComposerSuggestions("/archive 019abcde-0000-7000-8000-000000000001 ", notes, [], threads)).toEqual([]);
});
it("does not suggest threads for /fork arguments", () => {

View file

@ -18,6 +18,7 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
forkThread: vi.fn().mockResolvedValue(undefined),
rollbackThread: vi.fn().mockResolvedValue(undefined),
compactThread: vi.fn().mockResolvedValue(undefined),
archiveThread: vi.fn().mockResolvedValue(undefined),
toggleFastMode: vi.fn(),
toggleCollaborationMode: vi.fn(),
addSystemMessage: vi.fn(),
@ -222,6 +223,59 @@ describe("slash commands", () => {
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Compaction requested.");
});
it("archives the active thread for bare /archive", async () => {
const ctx = context({ activeThreadId: "active-thread" });
await executeSlashCommand("archive", "", ctx);
expect(ctx.archiveThread).toHaveBeenCalledWith("active-thread");
});
it("archives a selected thread by id argument", async () => {
const ctx = context({
listedThreads: [thread({ id: "thread-alpha", name: "Alpha" }), thread({ id: "thread-beta", name: "Beta" })],
});
await executeSlashCommand("archive", "thread-beta", ctx);
expect(ctx.archiveThread).toHaveBeenCalledWith("thread-beta");
});
it("rejects /archive without an active thread", async () => {
const ctx = context({ activeThreadId: null });
await executeSlashCommand("archive", "", ctx);
expect(ctx.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("No active thread to archive.");
});
it("rejects /archive while a turn is running", async () => {
const ctx = context({ busy: true });
await executeSlashCommand("archive", "thread-1", ctx);
expect(ctx.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Finish or interrupt the current turn before archiving threads.");
});
it("reports ambiguous archive matches", async () => {
const ctx = context({
listedThreads: [thread({ id: "thread-alpha", name: "Draft" }), thread({ id: "thread-beta", name: "Draft notes" })],
});
await executeSlashCommand("archive", "Draft", ctx);
expect(ctx.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes");
});
it("documents archive", () => {
expect(slashCommandHelpLines().find((line) => line.startsWith("/archive"))).toBe(
"/archive - Archive the active or selected Codex thread.",
);
});
it("documents that /plan can take a message", () => {
expect(slashCommandHelpLines().find((line) => line.startsWith("/plan"))).toBe(
"/plan - Toggle Plan mode, optionally sending a message.",