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