Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3009492
feat(merges): add dismiss actions for failed beads on Merge Queue pag…
jrf0110 Apr 10, 2026
7c90fc6
feat(gastown): add town ID copy badge and Debug settings section (#2296)
jrf0110 Apr 10, 2026
b7b52cd
chore(gastown): remove dead popReviewQueue and update stale comments …
jrf0110 Apr 10, 2026
d6a58b3
fix(gastown): prevent triage batch bead dispatch loop with wrong syst…
jrf0110 Apr 10, 2026
82be7ec
feat(gastown): add cmake and pkg-config to container images (#2060)
breno Apr 13, 2026
d7a83b5
feat(gastown): add Java JDK to container images (#2066)
breno Apr 13, 2026
a2946d8
fix(gastown): propagate custom env_vars to running containers on sett…
jrf0110 Apr 13, 2026
753017b
chore(gastown): remove dead code from patrol/scheduling/review-queue …
jrf0110 Apr 13, 2026
bf3ab64
fix(gastown): break create_landing_mr infinite loop (#2260) (#2371)
jrf0110 Apr 13, 2026
d92064e
fix(gastown): prevent deleteAgent from reopening terminal beads; bump…
jrf0110 Apr 14, 2026
41141f9
chore(gastown): bump max_instances to 810
jrf0110 Apr 15, 2026
999a895
chore(gastown): update @kilocode/sdk and @kilocode/plugin to 7.2.7 RC
jrf0110 Apr 15, 2026
e60ab86
chore: update pnpm-lock.yaml for @kilocode/sdk 7.2.7
jrf0110 Apr 15, 2026
1b30c7b
fix(gastown): revert pinned container image to local Dockerfile ref
jrf0110 Apr 15, 2026
7033331
chore(gastown): pin @kilocode/cli to 7.2.7 in container Dockerfiles
jrf0110 Apr 15, 2026
307facf
fix(gastown): exclude landing MR beads from orphan cleanup; allow fai…
jrf0110 Apr 15, 2026
562eeac
chore(gastown): update @kilocode/sdk, plugin, and CLI to 7.2.14
jrf0110 Apr 17, 2026
e48aad6
feat(gastown): auto-resolve merge conflicts on PRs (#2427) (#2484)
jrf0110 Apr 18, 2026
8e4e2c1
feat(gastown): add bulk bead deletion — array support for gt_bead_del…
jrf0110 Apr 18, 2026
93cbb37
chore(gastown): fix prod container image ref to Dockerfile; update pn…
jrf0110 Apr 20, 2026
83e4292
fix(gastown): address PR #2374 review comments
Apr 20, 2026
c0a0b98
fix: resolve type errors across gastown-staging branch
jrf0110 Apr 20, 2026
aa0605b
fix: formatting, lint errors, and bulk delete rig ID mismatch
jrf0110 Apr 20, 2026
0c67ec4
fix(gastown): recover from stale kilo.db on session.create failure
jrf0110 Apr 22, 2026
e5be91f
chore(gastown): adjust max_instances to 800
jrf0110 Apr 22, 2026
2f45e69
feat(gastown): instrument container cold-start and mayor availability…
jrf0110 Apr 22, 2026
5e3db97
feat(gastown): add convoy membership editing — gt_bead_update depends…
jrf0110 Apr 22, 2026
f51fd29
feat(grafana): add container startup latency panels (p50/p90/p99) (#2…
jrf0110 Apr 22, 2026
c8e6731
feat(gastown): update town defaults, free-tier small_model, and custo…
jrf0110 Apr 23, 2026
d0bee90
feat(gastown): measure true container cold-start and mayor-ready latency
jrf0110 Apr 23, 2026
00905b4
chore: resolve stale rebase conflicts and fix types.ts overload error
jrf0110 Apr 23, 2026
7544e67
fix(gastown): don't retroactively apply #2725 town-config defaults to…
jrf0110 Apr 23, 2026
cd49242
fix(gastown): address PR #2374 review feedback — readiness key leak a…
jrf0110 Apr 23, 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 });
Comment thread
jrf0110 marked this conversation as resolved.
}
} 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
Loading