Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,205 changes: 4,205 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

49 changes: 42 additions & 7 deletions src/backend/db/database.ts
Original file line numberDiff line numberDiff line change
@@ -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";

Expand DownExpand Up@@ -32,6 +32,7 @@ interface RawTicket {
worktree: string | null;
branch: string | null;
agentTitle: string | null;
archivedAt: number | null;
createdAt: number;
updatedAt: number;
}
Expand All@@ -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 = `
Expand DownExpand Up@@ -132,13 +134,46 @@ export const ticketStmts = {
).run(args);
},
},
archive: {
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: {
all: (): Ticket[] =>
db
.query<RawTicket, []>(`SELECT ${TICKET_COLS} FROM tickets ORDER BY created_at DESC`)
.query<RawTicket, []>(
`SELECT ${TICKET_COLS} FROM tickets WHERE archived_at IS NULL ORDER BY created_at DESC`,
)
.all()
.map(mapTicket),
},
listArchived: {
all: (): Ticket[] =>
db
.query<RawTicket, []>(
`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 }): 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: {
run: (args: { $agentTitle: string; $updatedAt: number; $id: string }): void => {
db.query(
Expand Down
8 changes: 8 additions & 0 deletions src/backend/db/migrations/011_add_archive.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import type { Migration } from "../migrator.ts";

export default {
name: "011_add_archive",
up(db) {
db.run("ALTER TABLE tickets ADD COLUMN archived_at INTEGER DEFAULT NULL");
},
} satisfies Migration;
15 changes: 14 additions & 1 deletion src/backend/db/migrations/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
];
Comment thread
roulpriya marked this conversation as resolved.
35 changes: 35 additions & 0 deletions src/backend/routes/tickets.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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()) {
Expand DownExpand Up@@ -175,6 +177,39 @@ export function ticketsRouter(orchestrator: OrchestratorService) {
}
});

app.post("/:id/archive", async (c) => {
const id = c.req.param("id");
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 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 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);
}

const updated = ticketStmts.get.get(id);
broadcastNotification({ tickets: ticketStmts.list.all(), type: "kanban-sync" });
return c.json(updated);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

app.delete("/:id", async (c) => {
const id = c.req.param("id");
const existing = ticketStmts.get.get(id);
Expand Down
1 change: 1 addition & 0 deletions src/common/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,7 @@ export interface Ticket {
worktree?: string | null;
branch?: string | null;
agentTitle?: string | null;
archivedAt?: number | null;
createdAt: number;
updatedAt: number;
}
Expand Down
9 changes: 8 additions & 1 deletion src/frontend/App.tsx
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom";

import { ArchiveDrawer } from "./components/ArchiveDrawer";
Comment thread
roulpriya marked this conversation as resolved.
import { CreateTicketModal } from "./components/CreateTicketModal";
import { IntegrationsModal } from "./components/IntegrationsModal";
import { KanbanBoard } from "./components/kanban-board/KanbanBoard";
Expand All@@ -22,20 +23,26 @@ 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), []);
const closeIntegrations = useCallback(() => setIntegrationsOpen(false), []);

return (
<div className="h-full flex flex-col bg-forge-black overflow-hidden">
<Header onOpenShell={openShell} onOpenIntegrations={openIntegrations} />
<Header
onOpenShell={openShell}
onOpenIntegrations={openIntegrations}
onOpenArchive={openArchive}
/>
<main className="flex-1 overflow-hidden">
<KanbanBoard />
</main>
<CreateTicketModal />
<IntegrationsModal open={integrationsOpen} onClose={closeIntegrations} />
{shellOpen && <ShellTerminal onClose={closeShell} />}
{isArchiveOpen && <ArchiveDrawer onClose={closeArchive} />}
</div>
);
}
Expand Down
119 changes: 119 additions & 0 deletions src/frontend/components/ArchiveDrawer.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
import { Archive, RotateCcw, X } from "lucide-react";
import { useCallback, type MouseEvent } from "react";

import { useStore } from "../store";

interface Props {
onClose: () => void;
}

export function ArchiveDrawer({ onClose }: Props) {
const { archivedTickets, isFetchingArchived, unarchiveTicket } = useStore();
const titleId = "archive-drawer-title";

const handleUnarchive = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
const id = e.currentTarget.dataset.id;
if (id) unarchiveTicket(id);
},
[unarchiveTicket],
);

return (
<>
{/* Backdrop */}
<div aria-hidden="true" className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />

{/* Drawer */}
<div
aria-labelledby={titleId}
aria-modal="true"
className="fixed right-0 top-0 bottom-0 w-[400px] z-50 flex flex-col bg-forge-panel border-l border-forge-border shadow-2xl"
role="dialog"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-forge-border flex-shrink-0">
<div className="flex items-center gap-2">
<Archive size={14} className="text-forge-amber" strokeWidth={1.5} />
<span
className="text-xs uppercase tracking-widest font-semibold text-forge-amber"
id={titleId}
>
ARCHIVE
</span>
{!isFetchingArchived && (
<span className="text-forge-text-muted text-xs">[{archivedTickets.length}]</span>
)}
</div>
<button
aria-label="Close archive drawer"
className="text-forge-text-muted hover:text-forge-text transition-colors"
onClick={onClose}
type="button"
>
<X size={14} strokeWidth={1.5} />
</button>
</div>

{/* Body */}
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
{isFetchingArchived && (
<div className="flex items-center justify-center h-32">
<span className="text-forge-text-muted text-xs uppercase tracking-widest">
LOADING...
</span>
</div>
)}

{!isFetchingArchived && archivedTickets.length === 0 && (
<div className="flex flex-col items-center justify-center h-32 gap-2">
<Archive size={24} className="text-forge-text-muted" strokeWidth={1} />
<span className="text-forge-text-muted text-xs uppercase tracking-widest">
NO ARCHIVED TICKETS
</span>
</div>
)}

{!isFetchingArchived &&
archivedTickets.map((ticket) => (
<div key={ticket.id} className="forge-surface group">
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-1">
<p className="text-forge-text-bright text-xs leading-snug font-medium flex-1">
{ticket.title}
</p>
<button
className="text-forge-text-muted hover:text-forge-green transition-colors opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 focus:opacity-100 flex-shrink-0 flex items-center gap-1"
data-id={ticket.id}
onClick={handleUnarchive}
title="Restore ticket"
type="button"
>
<RotateCcw size={12} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-widest">RESTORE</span>
</button>
</div>
<div className="px-3 pb-3">
{ticket.agentTitle && (
<p className="text-forge-accent text-xs leading-snug mb-1 font-mono opacity-80">
↳ {ticket.agentTitle}
</p>
)}
{ticket.description && (
<p className="text-forge-text-dim text-xs leading-relaxed line-clamp-2 mb-2">
{ticket.description}
</p>
)}
<div className="flex items-center justify-between">
<span className="text-forge-text-muted text-xs uppercase tracking-widest">
{ticket.status}
</span>
<span className="text-forge-text-muted text-xs">#{ticket.id.slice(0, 6)}</span>
</div>
</div>
</div>
))}
</div>
</div>
</>
);
}
21 changes: 19 additions & 2 deletions src/frontend/components/kanban-board/TicketCard.tsx
Original file line numberDiff line numberDiff line change
@@ -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";
Expand DownExpand Up@@ -31,7 +31,7 @@ const AGENT_STATUS_LABEL: Record<string, string> = {
};

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

Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -116,6 +124,15 @@ export function TicketCard({ ticket, agent }: Props) {
)}
</button>
)}
{!confirmDiscard && (
<button
className="text-forge-text-muted hover:text-forge-amber transition-colors opacity-0 group-hover:opacity-100"
onClick={handleArchiveClick}
title="Archive ticket"
>
<Archive size={13} strokeWidth={1.2} />
</button>
)}
{confirmDiscard ? (
<button
className="text-xs text-forge-red border border-forge-red px-1.5 py-0.5 uppercase tracking-widest hover:bg-forge-red hover:text-forge-black transition-colors"
Expand Down
Loading
Loading