diff --git a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx
index 9458bc0aff..ff9186841d 100644
--- a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx
+++ b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx
@@ -1,15 +1,36 @@
'use client';
+import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useGastownTRPC } from '@/lib/gastown/trpc';
-import { GitMerge, AlertCircle, Loader2 } from 'lucide-react';
+import { GitMerge, AlertCircle, Loader2, Activity, Server } from 'lucide-react';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
import { NeedsAttention } from './NeedsAttention';
+import { RefineryActivityLog } from './RefineryActivityLog';
+
+const ALL_RIGS = '__all__';
export function MergesPageClient({ townId }: { townId: string }) {
const trpc = useGastownTRPC();
+ const [selectedRigId, setSelectedRigId] = useState(ALL_RIGS);
+
+ const rigIdParam = selectedRigId === ALL_RIGS ? undefined : selectedRigId;
+
+ const rigsQuery = useQuery(trpc.gastown.listRigs.queryOptions({ townId }));
+ const rigs = rigsQuery.data ?? [];
const mergeQueueQuery = useQuery({
- ...trpc.gastown.getMergeQueueData.queryOptions({ townId }),
+ ...trpc.gastown.getMergeQueueData.queryOptions({
+ townId,
+ rigId: rigIdParam,
+ limit: 200,
+ }),
refetchInterval: 5_000,
});
@@ -33,6 +54,27 @@ export function MergesPageClient({ townId }: { townId: string }) {
)}
+
+ {/* Rig filter */}
+
+
+
+
{/* Content */}
@@ -60,13 +102,35 @@ export function MergesPageClient({ townId }: { townId: string }) {
-
+
Needs Your Attention
-
+
+ {totalAttention > 0 && (
+
+ {totalAttention}
+
+ )}
)}
+
+ {/* Refinery Activity Log section */}
+ {mergeQueueQuery.data && (
+
+
+
+
+ Refinery Activity Log
+
+
+
+
+ )}
diff --git a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx
new file mode 100644
index 0000000000..8bc905c501
--- /dev/null
+++ b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx
@@ -0,0 +1,531 @@
+'use client';
+
+import { useState, useMemo } from 'react';
+import { formatDistanceToNow } from 'date-fns';
+import {
+ GitMerge,
+ GitPullRequest,
+ GitBranch,
+ AlertTriangle,
+ RotateCcw,
+ Send,
+ XCircle,
+ Activity,
+} from 'lucide-react';
+import { motion, AnimatePresence } from 'motion/react';
+import { useDrawerStack } from '@/components/gastown/DrawerStack';
+import type { GastownOutputs } from '@/lib/gastown/trpc';
+
+type MergeQueueData = GastownOutputs['gastown']['getMergeQueueData'];
+type ActivityLogEntry = MergeQueueData['activityLog'][number];
+
+type ActionType =
+ | 'merged'
+ | 'failed'
+ | 'pr_created'
+ | 'pr_creation_failed'
+ | 'rework_requested'
+ | 'review_submitted'
+ | 'status_changed';
+
+const ACTION_CONFIG: Record<
+ ActionType,
+ {
+ icon: typeof GitMerge;
+ dotColor: string;
+ lineColor: string;
+ }
+> = {
+ merged: {
+ icon: GitMerge,
+ dotColor: 'bg-emerald-400',
+ lineColor: 'border-emerald-500/30',
+ },
+ failed: {
+ icon: XCircle,
+ dotColor: 'bg-red-400',
+ lineColor: 'border-red-500/30',
+ },
+ pr_created: {
+ icon: GitPullRequest,
+ dotColor: 'bg-sky-400',
+ lineColor: 'border-sky-500/30',
+ },
+ pr_creation_failed: {
+ icon: AlertTriangle,
+ dotColor: 'bg-red-400',
+ lineColor: 'border-red-500/30',
+ },
+ rework_requested: {
+ icon: RotateCcw,
+ dotColor: 'bg-amber-400',
+ lineColor: 'border-amber-500/30',
+ },
+ review_submitted: {
+ icon: Send,
+ dotColor: 'bg-indigo-400',
+ lineColor: 'border-indigo-500/30',
+ },
+ status_changed: {
+ icon: Activity,
+ dotColor: 'bg-white/40',
+ lineColor: 'border-white/10',
+ },
+};
+
+function isActionType(value: string): value is ActionType {
+ return value in ACTION_CONFIG;
+}
+
+function resolveActionType(entry: ActivityLogEntry): ActionType {
+ const eventType = entry.event.event_type;
+ if (eventType === 'review_completed') {
+ return entry.event.new_value === 'merged' ? 'merged' : 'failed';
+ }
+ if (isActionType(eventType)) {
+ return eventType;
+ }
+ return 'status_changed';
+}
+
+function extractPrNumber(prUrl: string | null): string | null {
+ if (!prUrl) return null;
+ const match = /\/pull\/(\d+)/.exec(prUrl);
+ return match ? match[1] : null;
+}
+
+function extractMessage(entry: ActivityLogEntry): string | null {
+ const meta = entry.event.metadata;
+ if (typeof meta.message === 'string') return meta.message;
+ if (typeof meta.feedback === 'string') return meta.feedback;
+ if (typeof meta.reason === 'string') return meta.reason;
+ return null;
+}
+
+function buildDescription(entry: ActivityLogEntry): {
+ prefix: string;
+ beadTitle: string;
+ suffix: string;
+} {
+ const action = resolveActionType(entry);
+ const agentName = entry.agent?.name ?? 'an agent';
+ const beadTitle = entry.sourceBead?.title ?? entry.mrBead?.title ?? 'untitled bead';
+ const targetBranch = entry.reviewMetadata?.target_branch;
+
+ const branchSuffix = targetBranch
+ ? targetBranch === 'main'
+ ? ' into main'
+ : ` into ${targetBranch}`
+ : '';
+
+ const convoySuffix = entry.convoy && branchSuffix ? ` (convoy: ${entry.convoy.title})` : '';
+
+ switch (action) {
+ case 'merged':
+ return {
+ prefix: `Refinery merged ${agentName}\u2019s `,
+ beadTitle: `\u201c${beadTitle}\u201d`,
+ suffix: `${branchSuffix}${convoySuffix}`,
+ };
+ case 'failed':
+ return {
+ prefix: `Refinery review failed for ${agentName}\u2019s `,
+ beadTitle: `\u201c${beadTitle}\u201d`,
+ suffix: '',
+ };
+ case 'pr_created': {
+ const prUrl = entry.reviewMetadata?.pr_url ?? null;
+ const prNum = extractPrNumber(prUrl);
+ const prLabel = prNum ? `PR #${prNum}` : 'a PR';
+ return {
+ prefix: `Refinery created ${prLabel} for ${agentName}\u2019s `,
+ beadTitle: `\u201c${beadTitle}\u201d`,
+ suffix: '',
+ };
+ }
+ case 'pr_creation_failed':
+ return {
+ prefix: `Refinery failed to create PR for ${agentName}\u2019s `,
+ beadTitle: `\u201c${beadTitle}\u201d`,
+ suffix: '',
+ };
+ case 'rework_requested':
+ return {
+ prefix: `Refinery requested changes from ${agentName} on `,
+ beadTitle: `\u201c${beadTitle}\u201d`,
+ suffix: '',
+ };
+ case 'review_submitted':
+ return {
+ prefix: `${agentName} submitted `,
+ beadTitle: `\u201c${beadTitle}\u201d`,
+ suffix: ' for review',
+ };
+ case 'status_changed':
+ return {
+ prefix: `Status changed on `,
+ beadTitle: `\u201c${beadTitle}\u201d`,
+ suffix: entry.event.new_value ? ` \u2192 ${entry.event.new_value}` : '',
+ };
+ }
+}
+
+// ── Convoy grouping ──────────────────────────────────────────────────
+
+type ConvoyInfo = NonNullable;
+
+type ConvoyActivityGroup = {
+ convoy: ConvoyInfo;
+ entries: ActivityLogEntry[];
+ latestTimestamp: string;
+};
+
+function groupActivityByConvoy(entries: ActivityLogEntry[]): {
+ convoyGroups: ConvoyActivityGroup[];
+ standalone: ActivityLogEntry[];
+} {
+ const convoyMap = new Map();
+ const standalone: ActivityLogEntry[] = [];
+
+ for (const entry of entries) {
+ if (entry.convoy) {
+ const existing = convoyMap.get(entry.convoy.convoy_id);
+ if (existing) {
+ existing.entries.push(entry);
+ if (entry.event.created_at > existing.latestTimestamp) {
+ existing.latestTimestamp = entry.event.created_at;
+ }
+ } else {
+ convoyMap.set(entry.convoy.convoy_id, {
+ convoy: entry.convoy,
+ entries: [entry],
+ latestTimestamp: entry.event.created_at,
+ });
+ }
+ } else {
+ standalone.push(entry);
+ }
+ }
+
+ // Sort convoy groups by most recent activity
+ const convoyGroups = [...convoyMap.values()].sort((a, b) =>
+ b.latestTimestamp.localeCompare(a.latestTimestamp)
+ );
+
+ return { convoyGroups, standalone };
+}
+
+// ── Main component ───────────────────────────────────────────────────
+
+const PAGE_SIZE = 20;
+
+export function RefineryActivityLog({
+ activityLog,
+ isLoading,
+ townId,
+}: {
+ activityLog: ActivityLogEntry[] | undefined;
+ isLoading: boolean;
+ townId: string;
+}) {
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
+ const entries = activityLog ?? [];
+
+ // Groups are sorted by most recent activity (latestTimestamp descending)
+ const { convoyGroups, standalone } = useMemo(() => groupActivityByConvoy(entries), [entries]);
+
+ // Paginate by convoy groups (keeps whole convoys together on a page).
+ // Each convoy group counts as its entry count toward the page budget;
+ // standalone entries fill the remaining budget after convoy groups.
+ const { visibleConvoyGroups, visibleStandalone, totalEntryCount, hasMore } = useMemo(() => {
+ const visibleConvoys: ConvoyActivityGroup[] = [];
+ let budget = visibleCount;
+
+ for (const group of convoyGroups) {
+ if (budget <= 0) break;
+ visibleConvoys.push(group);
+ budget -= group.entries.length;
+ }
+
+ // Fill remaining budget with standalone entries (already sorted by recency from the query)
+ const standaloneSlice = budget > 0 ? standalone.slice(0, budget) : [];
+
+ const total = convoyGroups.reduce((sum, g) => sum + g.entries.length, 0) + standalone.length;
+
+ return {
+ visibleConvoyGroups: visibleConvoys,
+ visibleStandalone: standaloneSlice,
+ totalEntryCount: total,
+ hasMore: visibleCount < total,
+ };
+ }, [convoyGroups, standalone, visibleCount]);
+
+ // All hooks are above — early returns below
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (entries.length === 0) {
+ return (
+
+
+ No refinery activity yet
+
+ Merge reviews, PR creations, and rework requests will appear here.
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Convoy grouped entries — sorted by most recent activity */}
+
+ {visibleConvoyGroups.map(group => (
+
+ ))}
+
+
+ {/* Standalone entries */}
+ {visibleStandalone.length > 0 && (
+
+
+ {visibleStandalone.map((entry, i, arr) => (
+
+ ))}
+
+
+ )}
+
+
+ {hasMore && (
+
+ )}
+
+ );
+}
+
+// ── Convoy activity group card ───────────────────────────────────────
+
+function ConvoyActivityGroupCard({
+ convoy,
+ entries,
+ townId,
+}: {
+ convoy: ConvoyInfo;
+ entries: ActivityLogEntry[];
+ townId: string;
+}) {
+ const { open: openDrawer } = useDrawerStack();
+ const progress =
+ convoy.total_beads > 0 ? `${convoy.closed_beads}/${convoy.total_beads} beads reviewed` : '';
+
+ return (
+
+ {/* Convoy header */}
+
+
+ {/* Progress bar */}
+ {convoy.total_beads > 0 && (
+
+
+
+ )}
+
+ {/* Timeline entries within convoy */}
+
+
+ {entries.map((entry, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+// ── Timeline entry ───────────────────────────────────────────────────
+
+function TimelineEntry({
+ entry,
+ isLast,
+ delay,
+}: {
+ entry: ActivityLogEntry;
+ isLast: boolean;
+ delay: number;
+}) {
+ const { open } = useDrawerStack();
+ const action = resolveActionType(entry);
+ const config = ACTION_CONFIG[action];
+ const Icon = config.icon;
+ const description = buildDescription(entry);
+ const message = extractMessage(entry);
+ const commitSha = entry.reviewMetadata?.merge_commit ?? null;
+ const prUrl = entry.reviewMetadata?.pr_url ?? null;
+ const prNumber = extractPrNumber(prUrl);
+ const rigName = entry.rigName;
+ const rigId = entry.mrBead?.rig_id;
+
+ function handleBeadClick() {
+ const beadId = entry.sourceBead?.bead_id ?? entry.mrBead?.bead_id;
+ if (beadId && rigId) {
+ open({ type: 'bead', beadId, rigId });
+ }
+ }
+
+ return (
+
+ {/* Timeline indicator */}
+
+
+ {!isLast && (
+
+ )}
+
+
+ {/* Content */}
+
+ {/* Rig name + timestamp header */}
+
+ {rigName && {rigName}}
+ {rigName && ·}
+ {formatDistanceToNow(new Date(entry.event.created_at), { addSuffix: true })}
+
+
+
+ {/* Main description */}
+
+ {description.prefix}
+
+ {description.suffix}
+
+
+ {/* Reason/message line */}
+ {message && (
+
+ {action === 'rework_requested' ? 'Reason: ' : ''}
+ {message}
+
+ )}
+
+ {/* Metadata line */}
+
+ {commitSha &&
Commit {commitSha.slice(0, 7)}}
+ {prUrl && prNumber && (
+
+ PR #{prNumber}
+
+ )}
+ {prUrl && !prNumber && (
+
+ View PR
+
+ )}
+
+
+
+ );
+}