From 079e936fb3b8b9632df4816f4ebe9aad936ac914 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 15:10:52 +0000 Subject: [PATCH 1/2] feat(gastown): add convoy grouping and per-rig filtering to Merge Queue page - Add RefineryActivityLog with convoy grouping: entries grouped by convoy with header cards showing title, branch, progress, clickable to open convoy drawer - Add per-rig filter dropdown using listRigs query and shadcn Select, passes rigId to getMergeQueueData for server-side filtering - Include status_changed ActionType with type guard (no unsafe 'as' cast) - Polish layout: page title, rig filter, Needs Attention, Activity Log sections with consistent headers and empty states --- .../[townId]/merges/MergesPageClient.tsx | 72 ++- .../[townId]/merges/RefineryActivityLog.tsx | 534 ++++++++++++++++++ 2 files changed, 602 insertions(+), 4 deletions(-) create mode 100644 src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx 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..d1fd61c5f2 --- /dev/null +++ b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx @@ -0,0 +1,534 @@ +'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 ?? []; + + const { convoyGroups, standalone } = useMemo(() => groupActivityByConvoy(entries), [entries]); + + 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. +

+
+ ); + } + + // Flatten all entries for pagination: convoy groups first, then standalone + const allGroupedEntries = useMemo(() => { + const result: Array<{ entry: ActivityLogEntry; convoyId: string | null }> = []; + for (const group of convoyGroups) { + for (const entry of group.entries) { + result.push({ entry, convoyId: group.convoy.convoy_id }); + } + } + for (const entry of standalone) { + result.push({ entry, convoyId: null }); + } + return result; + }, [convoyGroups, standalone]); + + const visibleGrouped = allGroupedEntries.slice(0, visibleCount); + const hasMore = visibleCount < allGroupedEntries.length; + + // Determine which convoy groups have visible entries + const visibleConvoyGroups = useMemo(() => { + const visibleConvoyIds = new Set(); + for (const { convoyId } of visibleGrouped) { + if (convoyId) visibleConvoyIds.add(convoyId); + } + return convoyGroups.filter(g => visibleConvoyIds.has(g.convoy.convoy_id)); + }, [visibleGrouped, convoyGroups]); + + return ( +
+
+ {/* Convoy grouped entries */} + + {visibleConvoyGroups.map(group => { + const groupEntries = visibleGrouped.filter(v => v.convoyId === group.convoy.convoy_id); + return ( + g.entry)} + townId={townId} + /> + ); + })} + + + {/* Standalone entries */} + {visibleGrouped.some(v => !v.convoyId) && ( +
+ + {visibleGrouped + .filter(v => !v.convoyId) + .map((v, 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 + + )} +
+
+ + ); +} From d9d522fae902c3e81f38dc672a4ec738f58ee6d9 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 15:57:42 +0000 Subject: [PATCH 2/2] fix(gastown): move hooks above early returns and paginate by convoy groups - Move all useMemo hooks above isLoading/empty early returns to prevent React crash when entries transition from 0 to non-zero (hooks must be called in the same order on every render) - Replace flat-entry pagination with group-based pagination: convoy groups (sorted by most recent activity) are kept whole, and standalone entries fill remaining page budget. This ensures recently active convoys appear on page 1 regardless of other convoys' sizes. --- .../[townId]/merges/RefineryActivityLog.tsx | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx index d1fd61c5f2..8bc905c501 100644 --- a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx +++ b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx @@ -231,8 +231,36 @@ export function RefineryActivityLog({ 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 (
@@ -275,64 +303,33 @@ export function RefineryActivityLog({ ); } - // Flatten all entries for pagination: convoy groups first, then standalone - const allGroupedEntries = useMemo(() => { - const result: Array<{ entry: ActivityLogEntry; convoyId: string | null }> = []; - for (const group of convoyGroups) { - for (const entry of group.entries) { - result.push({ entry, convoyId: group.convoy.convoy_id }); - } - } - for (const entry of standalone) { - result.push({ entry, convoyId: null }); - } - return result; - }, [convoyGroups, standalone]); - - const visibleGrouped = allGroupedEntries.slice(0, visibleCount); - const hasMore = visibleCount < allGroupedEntries.length; - - // Determine which convoy groups have visible entries - const visibleConvoyGroups = useMemo(() => { - const visibleConvoyIds = new Set(); - for (const { convoyId } of visibleGrouped) { - if (convoyId) visibleConvoyIds.add(convoyId); - } - return convoyGroups.filter(g => visibleConvoyIds.has(g.convoy.convoy_id)); - }, [visibleGrouped, convoyGroups]); - return (
- {/* Convoy grouped entries */} + {/* Convoy grouped entries — sorted by most recent activity */} - {visibleConvoyGroups.map(group => { - const groupEntries = visibleGrouped.filter(v => v.convoyId === group.convoy.convoy_id); - return ( - g.entry)} - townId={townId} - /> - ); - })} + {visibleConvoyGroups.map(group => ( + + ))} {/* Standalone entries */} - {visibleGrouped.some(v => !v.convoyId) && ( + {visibleStandalone.length > 0 && (
- {visibleGrouped - .filter(v => !v.convoyId) - .map((v, i, arr) => ( - - ))} + {visibleStandalone.map((entry, i, arr) => ( + + ))}
)} @@ -345,7 +342,7 @@ export function RefineryActivityLog({ > Show more - {allGroupedEntries.length - visibleCount} remaining + {totalEntryCount - visibleCount} remaining )}