Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ef4fae8
feat(merges): add dismiss actions for failed beads on Merge Queue pag…
jrf0110 Apr 10, 2026
2766b6a
feat(gastown): add town ID copy badge and Debug settings section (#2296)
jrf0110 Apr 10, 2026
17ed645
chore(gastown): remove dead popReviewQueue and update stale comments …
jrf0110 Apr 10, 2026
ace77e8
fix(gastown): prevent triage batch bead dispatch loop with wrong syst…
jrf0110 Apr 10, 2026
6e8173c
feat(gastown): add cmake and pkg-config to container images (#2060)
breno Apr 13, 2026
27dc5d1
feat(gastown): add Java JDK to container images (#2066)
breno Apr 13, 2026
69c0d99
fix(gastown): propagate custom env_vars to running containers on sett…
jrf0110 Apr 13, 2026
7157be6
chore(gastown): remove dead code from patrol/scheduling/review-queue …
jrf0110 Apr 13, 2026
77058b5
fix(gastown): break create_landing_mr infinite loop (#2260) (#2371)
jrf0110 Apr 13, 2026
ddad9ce
fix(gastown): prevent deleteAgent from reopening terminal beads; bump…
jrf0110 Apr 14, 2026
16301f3
chore(gastown): bump max_instances to 810
jrf0110 Apr 15, 2026
294c794
chore(gastown): update @kilocode/sdk and @kilocode/plugin to 7.2.7 RC
jrf0110 Apr 15, 2026
d6d4253
chore: update pnpm-lock.yaml for @kilocode/sdk 7.2.7
jrf0110 Apr 15, 2026
2402fad
fix(gastown): revert pinned container image to local Dockerfile ref
jrf0110 Apr 15, 2026
a7221ce
chore(gastown): pin @kilocode/cli to 7.2.7 in container Dockerfiles
jrf0110 Apr 15, 2026
a8acc5a
fix(gastown): exclude landing MR beads from orphan cleanup; allow fai…
jrf0110 Apr 15, 2026
3e6b223
chore(gastown): update @kilocode/sdk, plugin, and CLI to 7.2.14
jrf0110 Apr 17, 2026
07cdf3b
feat(gastown): auto-resolve merge conflicts on PRs (#2427) (#2484)
jrf0110 Apr 18, 2026
b15af68
feat(gastown): add bulk bead deletion — array support for gt_bead_del…
jrf0110 Apr 18, 2026
0f0d984
chore(gastown): fix prod container image ref to Dockerfile; update pn…
jrf0110 Apr 20, 2026
f267809
fix(gastown): address PR #2374 review comments
Apr 20, 2026
5aa0caa
chore: update pnpm lockfile
Apr 20, 2026
668f1ea
fix: resolve type errors across gastown-staging branch
jrf0110 Apr 20, 2026
35778ab
fix: formatting, lint errors, and bulk delete rig ID mismatch
jrf0110 Apr 20, 2026
674f77c
fix(gastown): recover from stale kilo.db on session.create failure
jrf0110 Apr 22, 2026
1f11003
chore(gastown): adjust max_instances to 800
jrf0110 Apr 22, 2026
e36ae4b
feat(gastown): instrument container cold-start and mayor availability…
jrf0110 Apr 22, 2026
28becb0
feat(gastown): add convoy membership editing — gt_bead_update depends…
jrf0110 Apr 22, 2026
c16bb08
feat(grafana): add container startup latency panels (p50/p90/p99) (#2…
jrf0110 Apr 22, 2026
57273ea
fix(gastown): validate staged status in convoyAddBead to prevent imme…
Apr 22, 2026
f5722e3
fix(gastown): guard convoyAddBead against failed convoys
Apr 22, 2026
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
12 changes: 12 additions & 0 deletions apps/web/src/app/(app)/gastown/[townId]/TownOverviewPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
ChevronDown,
Layers,
MessageSquare,
Copy,
} from 'lucide-react';
import { toast } from 'sonner';
import { formatDistanceToNow } from 'date-fns';
Expand Down Expand Up @@ -228,6 +229,17 @@ export function TownOverviewPageClient({
<span className="size-1.5 rounded-full bg-emerald-400" />
Live
</span>
<button
onClick={() => {
void navigator.clipboard.writeText(townId);
toast.success('Copied town ID');
}}
className="flex items-center gap-1 rounded px-1.5 py-0.5 font-mono text-xs text-white/30 hover:bg-white/[0.06] hover:text-white/60 transition-colors"
title={townId}
>
<Copy className="size-3" />
{townId.slice(0, 8)}…
</button>
</div>
<Button
variant="primary"
Expand Down
217 changes: 207 additions & 10 deletions apps/web/src/app/(app)/gastown/[townId]/beads/BeadsPageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
'use client';

import { useState, useMemo } from 'react';
import { useQuery, useQueries } from '@tanstack/react-query';
import { useState, useMemo, useCallback } from 'react';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { useGastownTRPC } from '@/lib/gastown/trpc';
import { useDrawerStack } from '@/components/gastown/DrawerStack';
import { Hexagon, Search } from 'lucide-react';
import { Hexagon, Search, Trash2, X } from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { formatDistanceToNow } from 'date-fns';
import { motion, AnimatePresence } from 'motion/react';
import type { GastownOutputs } from '@/lib/gastown/trpc';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

type Bead = GastownOutputs['gastown']['listBeads'][number];

Expand All @@ -23,11 +32,18 @@ const STATUS_DOT: Record<string, string> = {
failed: 'bg-red-400',
};

type DeleteConfirm =
| { kind: 'selected'; ids: string[]; rigId: string }
| { kind: 'all-failed'; count: number; rigIds: string[] };

export function BeadsPageClient({ townId }: BeadsPageClientProps) {
const trpc = useGastownTRPC();
const queryClient = useQueryClient();
const { open: openDrawer } = useDrawerStack();
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirm | null>(null);

const rigsQuery = useQuery(trpc.gastown.listRigs.queryOptions({ townId }));
const rigs = rigsQuery.data ?? [];
Expand Down Expand Up @@ -78,8 +94,92 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
return counts;
}, [allBeads]);

const failedBeads = useMemo(() => allBeads.filter(b => b.status === 'failed'), [allBeads]);

const isLoading = rigsQuery.isLoading || rigBeadQueries.some(q => q.isLoading);

const invalidateBeads = useCallback(() => {
for (const rig of rigs) {
void queryClient.invalidateQueries(trpc.gastown.listBeads.queryFilter({ rigId: rig.id }));
}
}, [queryClient, rigs, trpc.gastown.listBeads]);

const deleteBeadMutation = useMutation(
trpc.gastown.deleteBead.mutationOptions({
onSuccess: () => {
invalidateBeads();
setSelectedIds(new Set());
setDeleteConfirm(null);
},
})
);

const isDeleting = deleteBeadMutation.isPending;

// Build a map from bead_id -> rigId for lookups
const beadRigMap = useMemo(() => {
const map = new Map<string, string>();
for (const bead of allBeads) {
map.set(bead.bead_id, bead.rigId);
}
return map;
}, [allBeads]);

const allFilteredSelected =
filteredBeads.length > 0 && filteredBeads.every(b => selectedIds.has(b.bead_id));

const toggleSelectAll = () => {
if (allFilteredSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredBeads.map(b => b.bead_id)));
}
};

const toggleSelect = (beadId: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(beadId)) {
next.delete(beadId);
} else {
next.add(beadId);
}
return next;
});
};

const handleDeleteSelected = () => {
if (selectedIds.size === 0) return;
// Group by rigId — pick the first rig for simplicity (all selected beads share the same rig
// in most cases; if mixed, we use the first one and the mutation handles array input)
const selectedArr = [...selectedIds];
const firstRigId = beadRigMap.get(selectedArr[0] ?? '') ?? '';
setDeleteConfirm({ kind: 'selected', ids: selectedArr, rigId: firstRigId });
};

const handleDeleteAllFailed = () => {
if (failedBeads.length === 0) return;
const rigIds = [...new Set(failedBeads.map(b => b.rigId))];
setDeleteConfirm({ kind: 'all-failed', count: failedBeads.length, rigIds });
};

const handleConfirmDelete = () => {
if (!deleteConfirm) return;

if (deleteConfirm.kind === 'selected') {
const { ids } = deleteConfirm;
for (const id of ids) {
const rigId = beadRigMap.get(id) ?? '';
deleteBeadMutation.mutate({ rigId, beadId: id, townId });
}
} else {
// Delete all failed beads one by one (no bulk endpoint).
for (const bead of failedBeads) {
deleteBeadMutation.mutate({ rigId: bead.rigId, beadId: bead.bead_id, townId });
}
}
};

return (
<div className="flex h-full flex-col">
{/* Header */}
Expand All @@ -90,6 +190,17 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
<h1 className="text-lg font-semibold tracking-tight text-white/90">Beads</h1>
<span className="ml-1 font-mono text-xs text-white/30">{allBeads.length}</span>
</div>

{/* Delete all failed shortcut */}
{failedBeads.length > 0 && (
<button
onClick={handleDeleteAllFailed}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-red-400/70 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<Trash2 className="size-3" />
Delete all failed ({failedBeads.length})
</button>
)}
</div>

{/* Filter bar */}
Expand Down Expand Up @@ -127,6 +238,37 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
</div>
</div>

{/* Bulk action bar */}
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex items-center gap-3 border-b border-white/[0.06] bg-red-500/[0.04] px-6 py-2">
<span className="text-xs text-white/50">{selectedIds.size} selected</span>
<button
onClick={handleDeleteSelected}
className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5 text-xs font-medium text-red-400 transition-colors hover:bg-red-500/20"
>
<Trash2 className="size-3" />
Delete selected
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-white/30 transition-colors hover:text-white/50"
>
<X className="size-3" />
Clear
</button>
</div>
</motion.div>
)}
</AnimatePresence>

{/* Bead list */}
<div className="flex-1 overflow-y-auto">
{isLoading && (
Expand All @@ -153,6 +295,20 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
</div>
)}

{/* Select-all header row */}
{!isLoading && filteredBeads.length > 0 && (
<div className="flex items-center gap-3 border-b border-white/[0.04] px-6 py-1.5">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={toggleSelectAll}
className="size-3.5 cursor-pointer accent-[oklch(95%_0.15_108)]"
aria-label="Select all beads"
/>
<span className="text-[10px] text-white/20">Select all ({filteredBeads.length})</span>
</div>
)}

<AnimatePresence mode="popLayout">
{filteredBeads.map((bead, i) => (
<motion.div
Expand All @@ -161,16 +317,28 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ delay: Math.min(i * 0.02, 0.3), duration: 0.15 }}
onClick={() => {
const rigId = (bead as Bead & { rigId: string }).rigId;
openDrawer({ type: 'bead', beadId: bead.bead_id, rigId });
}}
className="group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02]"
className={`group flex cursor-pointer items-center gap-3 border-b border-white/[0.04] px-6 py-2.5 transition-colors hover:bg-white/[0.02] ${
selectedIds.has(bead.bead_id) ? 'bg-white/[0.03]' : ''
}`}
>
{/* Checkbox — stop propagation so clicking it doesn't open drawer */}
<input
type="checkbox"
checked={selectedIds.has(bead.bead_id)}
onChange={() => toggleSelect(bead.bead_id)}
onClick={e => e.stopPropagation()}
className="size-3.5 shrink-0 cursor-pointer accent-[oklch(95%_0.15_108)]"
aria-label={`Select bead ${bead.bead_id}`}
/>
<span
className={`size-2 shrink-0 rounded-full ${STATUS_DOT[bead.status] ?? 'bg-white/20'}`}
/>
<div className="min-w-0 flex-1">
<div
className="min-w-0 flex-1"
onClick={() => {
openDrawer({ type: 'bead', beadId: bead.bead_id, rigId: bead.rigId });
}}
>
<div className="flex items-center gap-2">
<span className="truncate text-sm text-white/80">{bead.title}</span>
<span className="shrink-0 rounded bg-white/[0.04] px-1.5 py-0.5 text-[9px] font-medium text-white/30">
Expand All @@ -180,7 +348,7 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-white/30">
<span className="font-mono">{bead.bead_id.slice(0, 8)}</span>
<span className="text-white/15">|</span>
<span>{(bead as Bead & { rigName: string }).rigName}</span>
<span>{bead.rigName}</span>
<span className="text-white/15">|</span>
<span>{formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })}</span>
</div>
Expand All @@ -191,6 +359,35 @@ export function BeadsPageClient({ townId }: BeadsPageClientProps) {
</AnimatePresence>
</div>

{/* Delete confirmation dialog */}
<Dialog
open={!!deleteConfirm}
onOpenChange={open => {
if (!open) setDeleteConfirm(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{deleteConfirm?.kind === 'all-failed' ? 'Delete all failed beads' : 'Delete beads'}
</DialogTitle>
<DialogDescription>
{deleteConfirm?.kind === 'all-failed'
? `Delete ${deleteConfirm.count} failed bead${deleteConfirm.count === 1 ? '' : 's'}? This cannot be undone.`
: `Delete ${deleteConfirm?.ids.length ?? 0} selected bead${(deleteConfirm?.ids.length ?? 0) === 1 ? '' : 's'}? This cannot be undone.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Drawers are rendered by the layout-level DrawerStackProvider */}
</div>
);
Expand Down
Loading