From b422d5baae10bcbb5cd078181bddc79e590a7d4f Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Sat, 23 May 2026 20:31:19 +0530 Subject: [PATCH 1/3] Add ticket archiving with separate archive view and restore functionality --- src/backend/db/database.ts | 39 ++++++++-- src/backend/db/migrations/registry.ts | 15 +++- src/backend/routes/tickets.ts | 35 +++++++++ src/common/types.ts | 1 + src/frontend/App.tsx | 9 ++- .../components/kanban-board/TicketCard.tsx | 21 +++++- src/frontend/components/layout/Header.tsx | 13 +++- src/frontend/lib/api.ts | 3 + src/frontend/store/store.ts | 72 +++++++++++++++++++ 9 files changed, 197 insertions(+), 11 deletions(-) diff --git a/src/backend/db/database.ts b/src/backend/db/database.ts index 177b545..ea67781 100644 --- a/src/backend/db/database.ts +++ b/src/backend/db/database.ts @@ -32,6 +32,7 @@ interface RawTicket { worktree: string | null; branch: string | null; agentTitle: string | null; + archivedAt: number | null; createdAt: number; updatedAt: number; } @@ -53,13 +54,14 @@ interface RawAgent { const TICKET_COLS = ` id, title, description, status, - base_branch AS baseBranch, - agent_id AS agentId, + base_branch AS baseBranch, + agent_id AS agentId, worktree, branch, - agent_title AS agentTitle, - created_at AS createdAt, - updated_at AS updatedAt + agent_title AS agentTitle, + archived_at AS archivedAt, + created_at AS createdAt, + updated_at AS updatedAt `; const AGENT_COLS = ` @@ -132,13 +134,38 @@ export const ticketStmts = { ).run(args); }, }, + archive: { + run: (args: { $archivedAt: number; $id: string }): void => { + db.query( + "UPDATE tickets SET archived_at = $archivedAt, updated_at = $archivedAt WHERE id = $id", + ).run(args); + }, + }, list: { all: (): Ticket[] => db - .query(`SELECT ${TICKET_COLS} FROM tickets ORDER BY created_at DESC`) + .query( + `SELECT ${TICKET_COLS} FROM tickets WHERE archived_at IS NULL ORDER BY created_at DESC`, + ) + .all() + .map(mapTicket), + }, + listArchived: { + all: (): Ticket[] => + db + .query( + `SELECT ${TICKET_COLS} FROM tickets WHERE archived_at IS NOT NULL ORDER BY archived_at DESC`, + ) .all() .map(mapTicket), }, + unarchive: { + run: (args: { $updatedAt: number; $id: string }): void => { + db.query("UPDATE tickets SET archived_at = NULL, updated_at = $updatedAt WHERE id = $id").run( + args, + ); + }, + }, updateAgentTitle: { run: (args: { $agentTitle: string; $updatedAt: number; $id: string }): void => { db.query( diff --git a/src/backend/db/migrations/registry.ts b/src/backend/db/migrations/registry.ts index 8ad51e2..9728be0 100644 --- a/src/backend/db/migrations/registry.ts +++ b/src/backend/db/migrations/registry.ts @@ -9,4 +9,17 @@ import m007 from "./007_add_claude_state.ts"; import m008 from "./008_add_acp_state.ts"; import m009 from "./009_add_agent_state.ts"; import m010 from "./010_add_diff_comments.ts"; -export const migrations: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010]; +import m011 from "./011_add_archive.ts"; +export const migrations: Migration[] = [ + m001, + m002, + m003, + m004, + m005, + m006, + m007, + m008, + m009, + m010, + m011, +]; diff --git a/src/backend/routes/tickets.ts b/src/backend/routes/tickets.ts index 588a5be..b0de884 100644 --- a/src/backend/routes/tickets.ts +++ b/src/backend/routes/tickets.ts @@ -19,6 +19,8 @@ export function ticketsRouter(orchestrator: OrchestratorService) { app.get("/", (c) => c.json(ticketStmts.list.all())); + app.get("/archived", (c) => c.json(ticketStmts.listArchived.all())); + app.post("/", async (c) => { const body = await c.req.json<{ title?: string; description?: string }>(); if (!body.title?.trim()) { @@ -175,6 +177,39 @@ export function ticketsRouter(orchestrator: OrchestratorService) { } }); + app.post("/:id/archive", async (c) => { + const id = c.req.param("id"); + const existing = ticketStmts.get.get(id); + if (!existing) { + return c.json({ error: "ticket not found" }, 404); + } + if (existing.archivedAt) { + return c.json({ error: "ticket already archived" }, 409); + } + + const now = Date.now(); + ticketStmts.archive.run({ $archivedAt: now, $id: id }); + const updated = ticketStmts.get.get(id); + broadcastNotification({ tickets: ticketStmts.list.all(), type: "kanban-sync" }); + return c.json(updated); + }); + + app.post("/:id/unarchive", (c) => { + const id = c.req.param("id"); + const existing = ticketStmts.get.get(id); + if (!existing) { + return c.json({ error: "ticket not found" }, 404); + } + if (!existing.archivedAt) { + return c.json({ error: "ticket is not archived" }, 409); + } + + ticketStmts.unarchive.run({ $updatedAt: Date.now(), $id: id }); + const updated = ticketStmts.get.get(id); + broadcastNotification({ tickets: ticketStmts.list.all(), type: "kanban-sync" }); + return c.json(updated); + }); + app.delete("/:id", async (c) => { const id = c.req.param("id"); const existing = ticketStmts.get.get(id); diff --git a/src/common/types.ts b/src/common/types.ts index 30e63b6..8632d5f 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -59,6 +59,7 @@ export interface Ticket { worktree?: string | null; branch?: string | null; agentTitle?: string | null; + archivedAt?: number | null; createdAt: number; updatedAt: number; } diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 650c6cf..122e740 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Route, Routes, useNavigate } from "react-router-dom"; +import { ArchiveDrawer } from "./components/ArchiveDrawer"; import { CreateTicketModal } from "./components/CreateTicketModal"; import { IntegrationsModal } from "./components/IntegrationsModal"; import { KanbanBoard } from "./components/kanban-board/KanbanBoard"; @@ -22,6 +23,7 @@ function NavigateFnRegistrar() { function KanbanPage() { const [shellOpen, setShellOpen] = useState(false); const [integrationsOpen, setIntegrationsOpen] = useState(false); + const { isArchiveOpen, openArchive, closeArchive } = useStore(); const openShell = useCallback(() => setShellOpen(true), []); const closeShell = useCallback(() => setShellOpen(false), []); const openIntegrations = useCallback(() => setIntegrationsOpen(true), []); @@ -29,13 +31,18 @@ function KanbanPage() { return (
-
+
{shellOpen && } + {isArchiveOpen && }
); } diff --git a/src/frontend/components/kanban-board/TicketCard.tsx b/src/frontend/components/kanban-board/TicketCard.tsx index 8e5ad1e..4fbd66f 100644 --- a/src/frontend/components/kanban-board/TicketCard.tsx +++ b/src/frontend/components/kanban-board/TicketCard.tsx @@ -1,7 +1,7 @@ import { useDraggable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; import { clsx } from "clsx"; -import { ChevronRight, Play, Trash2 } from "lucide-react"; +import { Archive, ChevronRight, Play, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useStore } from "../../store"; @@ -31,7 +31,7 @@ const AGENT_STATUS_LABEL: Record = { }; export function TicketCard({ ticket, agent }: Props) { - const { openTicket, activeTicketId, discardTicket, moveTicket } = useStore(); + const { openTicket, activeTicketId, discardTicket, moveTicket, archiveTicket } = useStore(); const [confirmDiscard, setConfirmDiscard] = useState(false); const [isLaunching, setIsLaunching] = useState(false); @@ -70,6 +70,14 @@ export function TicketCard({ ticket, agent }: Props) { [needsConfirm, confirmDiscard, discardTicket, ticket.id], ); + const handleArchiveClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + archiveTicket(ticket.id); + }, + [archiveTicket, ticket.id], + ); + const handleMouseLeave = useCallback(() => setConfirmDiscard(false), []); const handleRunClick = useCallback( @@ -116,6 +124,15 @@ export function TicketCard({ ticket, agent }: Props) { )} )} + {!confirmDiscard && ( + + )} {confirmDiscard ? ( + + + + + {/* Body */} +
+ {isFetchingArchived && ( +
+ + LOADING... + +
+ )} + + {!isFetchingArchived && archivedTickets.length === 0 && ( +
+ + + NO ARCHIVED TICKETS + +
+ )} + + {!isFetchingArchived && + archivedTickets.map((ticket) => ( +
+
+

+ {ticket.title} +

+ +
+
+ {ticket.agentTitle && ( +

+ ↳ {ticket.agentTitle} +

+ )} + {ticket.description && ( +

+ {ticket.description} +

+ )} +
+ + {ticket.status} + + #{ticket.id.slice(0, 6)} +
+
+
+ ))} +
+ + + ); +} From 90e08c04df991861509d84227559643a96bea220 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Sat, 23 May 2026 22:45:47 +0530 Subject: [PATCH 3/3] Improve ticket archive/unarchive with idempotent operations and accessibility fixes --- src/backend/db/database.ts | 26 +++++++++++++++-------- src/backend/routes/tickets.ts | 26 +++++++++++------------ src/frontend/components/ArchiveDrawer.tsx | 20 +++++++++++++---- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/backend/db/database.ts b/src/backend/db/database.ts index ea67781..86db263 100644 --- a/src/backend/db/database.ts +++ b/src/backend/db/database.ts @@ -1,4 +1,4 @@ -import { Database } from "bun:sqlite"; +import { Database, type Changes } from "bun:sqlite"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -135,10 +135,14 @@ export const ticketStmts = { }, }, archive: { - run: (args: { $archivedAt: number; $id: string }): void => { - db.query( - "UPDATE tickets SET archived_at = $archivedAt, updated_at = $archivedAt WHERE id = $id", - ).run(args); + run: (args: { $archivedAt: number; $id: string }): Changes => { + return db + .query( + `UPDATE tickets + SET archived_at = $archivedAt, updated_at = $archivedAt + WHERE id = $id AND archived_at IS NULL`, + ) + .run(args); }, }, list: { @@ -160,10 +164,14 @@ export const ticketStmts = { .map(mapTicket), }, unarchive: { - run: (args: { $updatedAt: number; $id: string }): void => { - db.query("UPDATE tickets SET archived_at = NULL, updated_at = $updatedAt WHERE id = $id").run( - args, - ); + run: (args: { $updatedAt: number; $id: string }): Changes => { + return db + .query( + `UPDATE tickets + SET archived_at = NULL, updated_at = $updatedAt + WHERE id = $id AND archived_at IS NOT NULL`, + ) + .run(args); }, }, updateAgentTitle: { diff --git a/src/backend/routes/tickets.ts b/src/backend/routes/tickets.ts index b0de884..48f23d2 100644 --- a/src/backend/routes/tickets.ts +++ b/src/backend/routes/tickets.ts @@ -179,16 +179,16 @@ export function ticketsRouter(orchestrator: OrchestratorService) { app.post("/:id/archive", async (c) => { const id = c.req.param("id"); - const existing = ticketStmts.get.get(id); - if (!existing) { - return c.json({ error: "ticket not found" }, 404); - } - if (existing.archivedAt) { + const now = Date.now(); + const result = ticketStmts.archive.run({ $archivedAt: now, $id: id }); + if (result.changes === 0) { + const existing = ticketStmts.get.get(id); + if (!existing) { + return c.json({ error: "ticket not found" }, 404); + } return c.json({ error: "ticket already archived" }, 409); } - const now = Date.now(); - ticketStmts.archive.run({ $archivedAt: now, $id: id }); const updated = ticketStmts.get.get(id); broadcastNotification({ tickets: ticketStmts.list.all(), type: "kanban-sync" }); return c.json(updated); @@ -196,15 +196,15 @@ export function ticketsRouter(orchestrator: OrchestratorService) { app.post("/:id/unarchive", (c) => { const id = c.req.param("id"); - const existing = ticketStmts.get.get(id); - if (!existing) { - return c.json({ error: "ticket not found" }, 404); - } - if (!existing.archivedAt) { + const result = ticketStmts.unarchive.run({ $updatedAt: Date.now(), $id: id }); + if (result.changes === 0) { + const existing = ticketStmts.get.get(id); + if (!existing) { + return c.json({ error: "ticket not found" }, 404); + } return c.json({ error: "ticket is not archived" }, 409); } - ticketStmts.unarchive.run({ $updatedAt: Date.now(), $id: id }); const updated = ticketStmts.get.get(id); broadcastNotification({ tickets: ticketStmts.list.all(), type: "kanban-sync" }); return c.json(updated); diff --git a/src/frontend/components/ArchiveDrawer.tsx b/src/frontend/components/ArchiveDrawer.tsx index b48fa9a..cf6922e 100644 --- a/src/frontend/components/ArchiveDrawer.tsx +++ b/src/frontend/components/ArchiveDrawer.tsx @@ -9,6 +9,7 @@ interface Props { export function ArchiveDrawer({ onClose }: Props) { const { archivedTickets, isFetchingArchived, unarchiveTicket } = useStore(); + const titleId = "archive-drawer-title"; const handleUnarchive = useCallback( (e: MouseEvent) => { @@ -21,15 +22,23 @@ export function ArchiveDrawer({ onClose }: Props) { return ( <> {/* Backdrop */} -
+