fix(dashboard): return authoritative archived state and cover ai-session archive

Archive/unarchive routes were returning the row-update boolean rather than
the actual archived flag, so a no-op unarchive replied with `archived: true`.
Re-read the row and report its current state instead. Also adds tests for
archive (terminal-only), unarchive event emission, and listAll filtering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-01 10:57:53 -07:00
parent c6fbedbd75
commit 9b415e7169
3 changed files with 83 additions and 6 deletions

View File

@@ -736,7 +736,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setMobileShowDetail(false); setMobileShowDetail(false);
} }
}, },
[planningSessions, resetDetailState, selectedSessionId, showArchived], [planningSessions, resetDetailState, selectedSessionId, setMobileShowDetail, showArchived],
); );
// Reset hasAutoStarted when modal closes // Reset hasAutoStarted when modal closes

View File

@@ -319,6 +319,81 @@ describe("AiSessionStore", () => {
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true); expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
}); });
describe("archive / unarchive / listAll", () => {
it("archive only flips terminal sessions and emits updated", () => {
seedSession({ id: "S-complete", status: "complete" });
seedSession({ id: "S-error", status: "error" });
seedSession({ id: "S-generating", status: "generating" });
seedSession({ id: "S-awaiting", status: "awaiting_input" });
const updated: string[] = [];
store.on("ai_session:updated", (summary) => updated.push(summary.id));
expect(store.archive("S-complete")).toBe(true);
expect(store.archive("S-error")).toBe(true);
expect(store.archive("S-generating")).toBe(false);
expect(store.archive("S-awaiting")).toBe(false);
expect(store.archive("missing")).toBe(false);
expect(store.get("S-complete")?.archived).toBe(1);
expect(store.get("S-error")?.archived).toBe(1);
expect(store.get("S-generating")?.archived ?? 0).toBe(0);
expect(updated.sort()).toEqual(["S-complete", "S-error"]);
});
it("unarchive flips archived sessions back and emits updated only when changed", () => {
seedSession({ id: "S-complete", status: "complete" });
store.archive("S-complete");
const updated: string[] = [];
store.on("ai_session:updated", (summary) => updated.push(summary.id));
expect(store.unarchive("S-complete")).toBe(true);
expect(store.get("S-complete")?.archived ?? 0).toBe(0);
expect(updated).toEqual(["S-complete"]);
// No-op unarchive of an already-unarchived row still updates the row
// (touches updatedAt) — this test pins the current behavior so the
// route handler can rely on `get()` for the authoritative state.
updated.length = 0;
expect(store.unarchive("S-complete")).toBe(true);
expect(store.get("S-complete")?.archived ?? 0).toBe(0);
expect(store.unarchive("missing")).toBe(false);
});
it("listAll excludes archived rows by default and includes them when requested", () => {
seedSession({ id: "S-active", status: "generating" });
seedSession({ id: "S-done-visible", status: "complete" });
seedSession({ id: "S-done-hidden", status: "complete" });
store.archive("S-done-hidden");
const visible = store.listAll();
expect(visible.map((s) => s.id).sort()).toEqual(["S-active", "S-done-visible"]);
const all = store.listAll(undefined, { includeArchived: true });
expect(all.map((s) => s.id).sort()).toEqual(["S-active", "S-done-hidden", "S-done-visible"]);
});
it("listAll filters by projectId with and without archived", () => {
seedSession({ id: "S-a-active", status: "generating", projectId: "project-a" });
seedSession({ id: "S-a-done", status: "complete", projectId: "project-a" });
seedSession({ id: "S-a-archived", status: "complete", projectId: "project-a" });
seedSession({ id: "S-b-done", status: "complete", projectId: "project-b" });
store.archive("S-a-archived");
const projectA = store.listAll("project-a");
expect(projectA.map((s) => s.id).sort()).toEqual(["S-a-active", "S-a-done"]);
const projectAWithArchived = store.listAll("project-a", { includeArchived: true });
expect(projectAWithArchived.map((s) => s.id).sort()).toEqual([
"S-a-active",
"S-a-archived",
"S-a-done",
]);
});
});
it("ping updates updatedAt for existing sessions without emitting updates", () => { it("ping updates updatedAt for existing sessions without emitting updates", () => {
seedSession({ id: "S-ping", status: "awaiting_input" }); seedSession({ id: "S-ping", status: "awaiting_input" });

View File

@@ -3455,8 +3455,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (session.status !== "complete" && session.status !== "error") { if (session.status !== "complete" && session.status !== "error") {
throw badRequest("Only completed or errored sessions can be archived"); throw badRequest("Only completed or errored sessions can be archived");
} }
const ok = aiSessionStore.archive(req.params.id); aiSessionStore.archive(req.params.id);
res.json({ archived: ok }); const after = aiSessionStore.get(req.params.id);
res.json({ archived: Number(after?.archived ?? 0) === 1 });
}); });
/** /**
@@ -3470,8 +3471,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (!aiSessionStore.get(req.params.id)) { if (!aiSessionStore.get(req.params.id)) {
throw notFound("Session not found"); throw notFound("Session not found");
} }
const ok = aiSessionStore.unarchive(req.params.id); aiSessionStore.unarchive(req.params.id);
res.json({ archived: !ok }); const after = aiSessionStore.get(req.params.id);
res.json({ archived: Number(after?.archived ?? 0) === 1 });
}); });
router.post("/ai-sessions/:id/lock", (req, res) => { router.post("/ai-sessions/:id/lock", (req, res) => {
@@ -4007,7 +4009,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Scripts and messaging routes are registered by registerMessagingScriptRoutes(). // Scripts and messaging routes are registered by registerMessagingScriptRoutes().
router.use((err: unknown, req: Request, res: Response, next: NextFunction) => { router.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) { if (res.headersSent) {
next(err); next(err);
return; return;