diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index 30fdbfa090..6455618030 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -792,6 +792,18 @@ const MrBeadRow = z.object({ agent_role: z.string().nullable(), // rig name rig_name: z.string().nullable(), + // failure event metadata (correlated subquery for failed MR beads) + failure_event_metadata: z + .string() + .nullable() + .transform((v): Record | null => { + if (!v) return null; + try { + return JSON.parse(v) as Record; + } catch { + return null; + } + }), }); /** Zod schema for an enriched activity log event row. */ @@ -830,6 +842,10 @@ const ActivityLogRow = z.object({ agent_role: z.string().nullable(), // rig info rig_name: z.string().nullable(), + // source bead (resolved via bead_dependencies tracks join) + source_bead_id: z.string().nullable(), + source_bead_title: z.string().nullable(), + source_bead_status: z.string().nullable(), // review metadata rm_branch: z.string().nullable(), rm_target_branch: z.string().nullable(), @@ -989,7 +1005,13 @@ export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): Me am.${agent_metadata.columns.bead_id} AS agent_id, agent_bead.${beads.columns.title} AS agent_name, am.${agent_metadata.columns.role} AS agent_role, - rig.name AS rig_name + rig.name AS rig_name, + (SELECT ${bead_events.metadata} + FROM ${bead_events} + WHERE ${bead_events.bead_id} = ${beads.bead_id} + AND ${bead_events.event_type} IN ('review_completed', 'pr_creation_failed') + ORDER BY ${bead_events.created_at} DESC + LIMIT 1) AS failure_event_metadata FROM ${beads} INNER JOIN ${review_metadata} ON ${beads.bead_id} = ${review_metadata.bead_id} @@ -1066,6 +1088,9 @@ export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): Me agent_bead.${beads.columns.title} AS agent_name, am.${agent_metadata.columns.role} AS agent_role, rig.name AS rig_name, + src.${beads.columns.bead_id} AS source_bead_id, + src.${beads.columns.title} AS source_bead_title, + src.${beads.columns.status} AS source_bead_status, rm.${review_metadata.columns.branch} AS rm_branch, rm.${review_metadata.columns.target_branch} AS rm_target_branch, rm.${review_metadata.columns.merge_commit} AS rm_merge_commit, @@ -1083,6 +1108,11 @@ export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): Me ON am.${agent_metadata.columns.bead_id} = ${bead_events.agent_id} LEFT JOIN ${beads} AS agent_bead ON agent_bead.${beads.columns.bead_id} = ${bead_events.agent_id} + LEFT JOIN ${bead_dependencies} AS dep + ON dep.${bead_dependencies.columns.bead_id} = b.${beads.columns.bead_id} + AND dep.${bead_dependencies.columns.dependency_type} = 'tracks' + LEFT JOIN ${beads} AS src + ON src.${beads.columns.bead_id} = dep.${bead_dependencies.columns.depends_on_bead_id} LEFT JOIN ${review_metadata} AS rm ON rm.${review_metadata.columns.bead_id} = ${bead_events.bead_id} LEFT JOIN ${convoy_metadata} AS cm @@ -1160,14 +1190,38 @@ function mrBeadRowToItem(row: z.output): MergeQueueItem { : null, rigName: row.rig_name, staleSince: null, - failureReason: null, + failureReason: + row.status === 'failed' && row.failure_event_metadata + ? typeof row.failure_event_metadata.message === 'string' + ? row.failure_event_metadata.message + : null + : null, }; } function eventRowToEntry(row: z.output): ActivityLogEntry { - // Try to find the source bead from the event's bead metadata - const sourceBeadId = - typeof row.bead_metadata?.source_bead_id === 'string' ? row.bead_metadata.source_bead_id : null; + // Source bead resolution: + // - Events on MR beads (pr_created, pr_creation_failed, rework_requested): + // resolved via bead_dependencies LEFT JOIN (source_bead_id/title/status columns) + // - Events on source beads (review_submitted, review_completed): + // the event's bead IS the source bead — use the bead columns directly + const isMrBeadEvent = row.bead_type === 'merge_request'; + + const resolvedSourceBead = isMrBeadEvent + ? row.source_bead_id + ? { + bead_id: row.source_bead_id, + title: row.source_bead_title ?? '', + status: row.source_bead_status ?? '', + } + : null + : row.bead_title + ? { + bead_id: row.bead_id, + title: row.bead_title, + status: row.bead_status ?? '', + } + : null; return { event: { @@ -1190,16 +1244,7 @@ function eventRowToEntry(row: z.output): ActivityLogEntry metadata: row.bead_metadata, } : null, - sourceBead: sourceBeadId - ? { - bead_id: sourceBeadId, - title: - typeof row.bead_metadata?.source_bead_title === 'string' - ? row.bead_metadata.source_bead_title - : '', - status: '', - } - : null, + sourceBead: resolvedSourceBead, convoy: row.convoy_id ? { convoy_id: row.convoy_id, diff --git a/src/app/(app)/gastown/[townId]/layout.tsx b/src/app/(app)/gastown/[townId]/layout.tsx index f473c173b7..38124aeabe 100644 --- a/src/app/(app)/gastown/[townId]/layout.tsx +++ b/src/app/(app)/gastown/[townId]/layout.tsx @@ -1,6 +1,7 @@ import { TerminalBarProvider } from '@/components/gastown/TerminalBarContext'; import { DrawerStackProvider } from '@/components/gastown/DrawerStack'; import { renderDrawerContent } from '@/components/gastown/DrawerStackContent'; +import { TerminalBarPadding } from '@/components/gastown/TerminalBarPadding'; import { MayorTerminalBar } from './MayorTerminalBar'; export default function TownLayout({ @@ -13,11 +14,7 @@ export default function TownLayout({ return ( - {/* Fullscreen edge-to-edge layout for gastown town pages. - Bottom padding clears the fixed terminal bar. */} -
-
{children}
-
+ {children}
diff --git a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx index 0b515a3481..9458bc0aff 100644 --- a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx @@ -2,72 +2,72 @@ import { useQuery } from '@tanstack/react-query'; import { useGastownTRPC } from '@/lib/gastown/trpc'; -import { GitMerge, CheckCircle } from 'lucide-react'; -import { formatDistanceToNow } from 'date-fns'; +import { GitMerge, AlertCircle, Loader2 } from 'lucide-react'; +import { NeedsAttention } from './NeedsAttention'; export function MergesPageClient({ townId }: { townId: string }) { const trpc = useGastownTRPC(); - const eventsQuery = useQuery({ - ...trpc.gastown.getTownEvents.queryOptions({ townId, limit: 200 }), + const mergeQueueQuery = useQuery({ + ...trpc.gastown.getMergeQueueData.queryOptions({ townId }), refetchInterval: 5_000, }); - const mergeEvents = (eventsQuery.data ?? []).filter( - e => e.event_type === 'review_submitted' || e.event_type === 'review_completed' - ); + const needsAttention = mergeQueueQuery.data?.needsAttention; + const totalAttention = needsAttention + ? needsAttention.openPRs.length + + needsAttention.failedReviews.length + + needsAttention.stalePRs.length + : 0; return (
+ {/* Page header */}

Merge Queue

- {mergeEvents.length} + {totalAttention > 0 && ( + + {totalAttention} + + )}
+ {/* Content */}
- {mergeEvents.length === 0 && ( -
- -

No merge activity yet.

-

- Review submissions and merge completions will appear here. -

-
- )} +
+ {/* Loading state */} + {mergeQueueQuery.isLoading && ( +
+ +

Loading merge queue…

+
+ )} + + {/* Error state */} + {mergeQueueQuery.isError && ( +
+ +

Failed to load merge queue data.

+

{mergeQueueQuery.error.message}

+
+ )} - {mergeEvents - .slice() - .reverse() - .map(event => { - const isCompleted = event.event_type === 'review_completed'; - return ( -
- {isCompleted ? ( - - ) : ( - - )} -
-
- {isCompleted ? 'Review completed' : 'Submitted for review'} - {event.new_value ? `: ${event.new_value}` : ''} -
-
- {event.rig_name && {event.rig_name}} - - {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} - -
-
+ {/* Needs Your Attention section */} + {needsAttention && ( +
+
+ + + Needs Your Attention +
- ); - })} + +
+ )} +
); diff --git a/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx b/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx new file mode 100644 index 0000000000..bb47bb5a20 --- /dev/null +++ b/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx @@ -0,0 +1,502 @@ +'use client'; + +import { useState, useMemo, Fragment } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useGastownTRPC } from '@/lib/gastown/trpc'; +import type { GastownOutputs } from '@/lib/gastown/trpc'; +import { useDrawerStack } from '@/components/gastown/DrawerStack'; +import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; +import { motion, AnimatePresence } from 'motion/react'; +import { + AlertTriangle, + ExternalLink, + Eye, + GitBranch, + GitMerge, + RefreshCw, + XCircle, + CheckCircle2, + Clock, +} from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; + +// ── Types ──────────────────────────────────────────────────────────── + +type MergeQueueData = GastownOutputs['gastown']['getMergeQueueData']; +type MergeQueueItem = MergeQueueData['needsAttention']['openPRs'][number]; + +type ConvoyGroup = { + convoy: NonNullable; + items: MergeQueueItem[]; +}; + +type ConfirmAction = { beadId: string; title: string; action: 'fail' | 'retry' }; + +// ── Status badges ──────────────────────────────────────────────────── + +const CATEGORY_STYLES = { + openPR: { + border: 'border-violet-500/30', + bg: 'bg-violet-500/10', + text: 'text-violet-300', + label: 'PR Open', + }, + failed: { + border: 'border-red-500/30', + bg: 'bg-red-500/10', + text: 'text-red-300', + label: 'Failed', + }, + stale: { + border: 'border-amber-500/30', + bg: 'bg-amber-500/10', + text: 'text-amber-300', + label: 'Stale', + }, +} as const; + +type Category = keyof typeof CATEGORY_STYLES; + +// ── Convoy grouping ────────────────────────────────────────────────── + +function groupByConvoy(items: MergeQueueItem[]): { + convoyGroups: ConvoyGroup[]; + standalone: MergeQueueItem[]; +} { + const convoyMap = new Map(); + const standalone: MergeQueueItem[] = []; + + for (const item of items) { + if (item.convoy) { + const existing = convoyMap.get(item.convoy.convoy_id); + if (existing) { + existing.items.push(item); + } else { + convoyMap.set(item.convoy.convoy_id, { + convoy: item.convoy, + items: [item], + }); + } + } else { + standalone.push(item); + } + } + + return { + convoyGroups: [...convoyMap.values()], + standalone, + }; +} + +// ── Main component ─────────────────────────────────────────────────── + +export function NeedsAttention({ + data, + townId, +}: { + data: MergeQueueData['needsAttention']; + townId: string; +}) { + const totalCount = data.openPRs.length + data.failedReviews.length + data.stalePRs.length; + + // Tag each item with its category for rendering + const allItems = useMemo(() => { + const tagged: Array<{ item: MergeQueueItem; category: Category }> = []; + for (const item of data.openPRs) tagged.push({ item, category: 'openPR' }); + for (const item of data.failedReviews) tagged.push({ item, category: 'failed' }); + for (const item of data.stalePRs) tagged.push({ item, category: 'stale' }); + return tagged; + }, [data]); + + // Group by convoy + const { convoyGroups, standalone } = useMemo(() => { + const allItemsFlat = allItems.map(t => t.item); + return groupByConvoy(allItemsFlat); + }, [allItems]); + + // Category lookup for rendering + const categoryByBeadId = useMemo(() => { + const map = new Map(); + for (const { item, category } of allItems) { + map.set(item.mrBead.bead_id, category); + } + return map; + }, [allItems]); + + if (totalCount === 0) { + return ( +
+ +

All clear — nothing needs your attention

+
+ ); + } + + return ( +
+ {/* Convoy groups */} + + {convoyGroups.map(group => ( + + + + ))} + + + {/* Standalone items (no convoy) */} + + {standalone.map((item, i) => ( + + + + ))} + +
+ ); +} + +// ── Convoy group card ──────────────────────────────────────────────── + +function ConvoyGroupCard({ + group, + categoryByBeadId, + townId, +}: { + group: ConvoyGroup; + categoryByBeadId: Map; + townId: string; +}) { + const { open: openDrawer } = useDrawerStack(); + const { convoy, items } = group; + const progress = + convoy.total_beads > 0 ? `${convoy.closed_beads}/${convoy.total_beads} beads reviewed` : ''; + + return ( +
+ {/* Convoy header */} + + + {/* Progress bar */} + {convoy.total_beads > 0 && ( +
+ +
+ )} + + {/* Items within convoy */} +
+ {items.map((item, i) => ( + + {i > 0 &&
} + + + ))} +
+
+ ); +} + +// ── Standalone attention item card ─────────────────────────────────── + +function AttentionItemCard({ + item, + category, + townId, +}: { + item: MergeQueueItem; + category: Category; + townId: string; +}) { + return ( +
+ +
+ ); +} + +// ── Shared row component (used inside convoy group and standalone) ─── + +function AttentionItemRow({ + item, + category, + townId, +}: { + item: MergeQueueItem; + category: Category; + townId: string; +}) { + const { open: openDrawer } = useDrawerStack(); + const trpc = useGastownTRPC(); + const queryClient = useQueryClient(); + const [confirmAction, setConfirmAction] = useState(null); + + const style = CATEGORY_STYLES[category]; + const sourceBeadTitle = item.sourceBead?.title ?? item.mrBead.title.replace(/^Review: /, ''); + const rigId = item.mrBead.rig_id ?? ''; + + const invalidateMergeQueue = () => { + void queryClient.invalidateQueries({ + queryKey: trpc.gastown.getMergeQueueData.queryKey({ townId }), + }); + }; + + // Retry review: reset the MR bead status back to 'open' so the refinery re-queues it. + // updateBead requires a rigId — use the MR bead's rig_id. + const retryMutation = useMutation( + trpc.gastown.updateBead.mutationOptions({ + onSuccess: () => { + setConfirmAction(null); + invalidateMergeQueue(); + toast.success('Review retry requested'); + }, + onError: (err: { message: string }) => { + toast.error(`Failed to retry: ${err.message}`); + }, + }) + ); + + // Fail bead mutation: use adminForceFailBead + const failMutation = useMutation( + trpc.gastown.adminForceFailBead.mutationOptions({ + onSuccess: () => { + setConfirmAction(null); + invalidateMergeQueue(); + toast.success('Bead marked as failed'); + }, + onError: (err: { message: string }) => { + toast.error(`Failed to update bead: ${err.message}`); + }, + }) + ); + + const isPending = retryMutation.isPending || failMutation.isPending; + + const handleConfirm = () => { + if (!confirmAction) return; + if (confirmAction.action === 'retry') { + retryMutation.mutate({ + rigId, + beadId: confirmAction.beadId, + status: 'open', + }); + } else { + failMutation.mutate({ + townId, + beadId: confirmAction.beadId, + }); + } + }; + + return ( + <> +
+ {/* Category indicator */} +
+ {category === 'openPR' && } + {category === 'failed' && } + {category === 'stale' && } +
+ + {/* Content */} +
+ {/* Title row */} +
+ + {style.label} + + +
+ + {/* Metadata row */} +
+ {item.rigName && {item.rigName}} + {item.agent && {item.agent.name}} + + {formatDistanceToNow(new Date(item.mrBead.created_at), { addSuffix: true })} + + {item.reviewMetadata.retry_count > 0 && ( + + {item.reviewMetadata.retry_count}{' '} + {item.reviewMetadata.retry_count === 1 ? 'retry' : 'retries'} + + )} + {category === 'stale' && item.staleSince && ( + + stale since {formatDistanceToNow(new Date(item.staleSince), { addSuffix: true })} + + )} + {category === 'failed' && item.failureReason && ( + {item.failureReason} + )} +
+
+ + {/* Actions */} +
+ {item.reviewMetadata.pr_url && ( + + + + )} + {category === 'failed' && ( + + )} + + +
+
+ + {/* Confirmation dialog for retry or fail */} + setConfirmAction(null)}> + + + + {confirmAction?.action === 'retry' ? 'Retry Review' : 'Fail Bead'} + + + {confirmAction?.action === 'retry' ? ( + <> + Re-queue {confirmAction.title} for review? + This resets the MR bead so the refinery picks it up again. + + ) : ( + <> + Mark {confirmAction?.title} as failed? This + stops the review process for this bead. + + )} + + + + + + + + + + ); +} diff --git a/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx b/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx index 65fa27910e..e472866a05 100644 --- a/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx +++ b/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx @@ -1,6 +1,7 @@ import { TerminalBarProvider } from '@/components/gastown/TerminalBarContext'; import { DrawerStackProvider } from '@/components/gastown/DrawerStack'; import { renderDrawerContent } from '@/components/gastown/DrawerStackContent'; +import { TerminalBarPadding } from '@/components/gastown/TerminalBarPadding'; import { MayorTerminalBar } from '@/app/(app)/gastown/[townId]/MayorTerminalBar'; export default async function OrgTownLayout({ @@ -16,11 +17,7 @@ export default async function OrgTownLayout({ return ( - {/* Fullscreen edge-to-edge layout for gastown town pages. - Bottom padding clears the fixed terminal bar. */} -
-
{children}
-
+ {children}
diff --git a/src/components/gastown/DrawerStack.tsx b/src/components/gastown/DrawerStack.tsx index e97ddbf120..eed3b366a3 100644 --- a/src/components/gastown/DrawerStack.tsx +++ b/src/components/gastown/DrawerStack.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from import { motion, AnimatePresence } from 'motion/react'; import { X, ChevronLeft } from 'lucide-react'; import type { TownEvent } from './ActivityFeed'; +import { useTerminalBar, COLLAPSED_SIZE } from './TerminalBarContext'; // ── Resource types ─────────────────────────────────────────────────────── @@ -80,7 +81,7 @@ export function DrawerStackProvider({ return ( {children} - void; + closeAll: () => void; + push: (resource: ResourceRef) => void; + renderContent: ( + resource: ResourceRef, + helpers: { push: (resource: ResourceRef) => void; close: () => void } + ) => ReactNode; +}) { + const { position, size, collapsed } = useTerminalBar(); + const rightOffset = + position === 'right' ? (collapsed ? COLLAPSED_SIZE : COLLAPSED_SIZE + size) : 0; + return ; +} + function DrawerStackRenderer({ stack, pop, closeAll, push, renderContent, + rightOffset = 0, }: { stack: DrawerStackEntry[]; pop: () => void; @@ -114,6 +136,7 @@ function DrawerStackRenderer({ resource: ResourceRef, helpers: { push: (resource: ResourceRef) => void; close: () => void } ) => ReactNode; + rightOffset?: number; }) { const isOpen = stack.length > 0; @@ -145,6 +168,7 @@ function DrawerStackRenderer({ isTop={isTop} onClose={isTop ? pop : undefined} onBack={index > 0 && isTop ? pop : undefined} + rightOffset={rightOffset} > {renderContent(entry.resource, { push, @@ -167,6 +191,7 @@ function DrawerLayer({ isTop, onClose, onBack, + rightOffset = 0, children, }: { depth: number; @@ -174,13 +199,14 @@ function DrawerLayer({ isTop: boolean; onClose?: () => void; onBack?: (() => void) | false; + rightOffset?: number; children: ReactNode; }) { const [hovered, setHovered] = useState(false); // Top layer: right: 0. Background layers: shift left by depth * offset. // On hover, background layers shift further left. - const rightOffset = isTop ? 0 : -(depth * DEPTH_OFFSET + (hovered ? HOVER_EXTRA : 0)); + const layerShift = isTop ? 0 : -(depth * DEPTH_OFFSET + (hovered ? HOVER_EXTRA : 0)); const scale = isTop ? 1 : 1 - depth * 0.015; const opacity = isTop ? 1 : 0.6 + (hovered ? 0.25 : 0); @@ -188,7 +214,7 @@ function DrawerLayer({ setHovered(false)} - className="fixed top-0 right-0 bottom-0 z-[61] flex flex-col outline-none" + className="fixed top-0 bottom-0 z-[61] flex flex-col outline-none" style={{ + right: rightOffset, width: DRAWER_WIDTH, maxWidth: '94vw', zIndex: 61 + (totalLayers - depth), diff --git a/src/components/gastown/TerminalBar.tsx b/src/components/gastown/TerminalBar.tsx index dd525af4b2..0e826331d6 100644 --- a/src/components/gastown/TerminalBar.tsx +++ b/src/components/gastown/TerminalBar.tsx @@ -6,15 +6,31 @@ import { useRouter } from 'next/navigation'; import { useGastownTRPC, gastownWsUrl } from '@/lib/gastown/trpc'; import { useSidebar } from '@/components/ui/sidebar'; -import { useTerminalBar } from './TerminalBarContext'; +import { + useTerminalBar, + COLLAPSED_SIZE, + isHorizontal, + clampSize, + type TerminalPosition, +} from './TerminalBarContext'; import { useDrawerStack } from './DrawerStack'; import { useXtermPty } from './useXtermPty'; -import { ChevronDown, ChevronUp, Crown, Activity, Terminal as TerminalIcon, X } from 'lucide-react'; +import { + ChevronDown, + ChevronUp, + ChevronLeft, + ChevronRight, + Crown, + Activity, + Terminal as TerminalIcon, + X, + PanelBottom, + PanelTop, + PanelLeft, + PanelRight, +} from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; -const COLLAPSED_HEIGHT = 38; -const EXPANDED_HEIGHT = 300; - type TerminalBarProps = { townId: string; /** Override base path for org-scoped routes (e.g. /organizations/[id]/gastown/[townId]) */ @@ -22,8 +38,9 @@ type TerminalBarProps = { }; /** - * Unified bottom terminal bar. Always shows a Mayor tab (non-closeable). + * Unified terminal bar. Always shows a Mayor tab (non-closeable). * Agent terminal tabs are opened/closed via TerminalBarContext. + * Can be positioned at bottom/top/right/left with drag-to-resize. */ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarProps) { const townBasePath = basePathOverride ?? `/gastown/${townId}`; @@ -32,17 +49,19 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP tabs: agentTabs, activeTabId, collapsed, + position, + size, closeTab, setActiveTabId, setCollapsed, + setPosition, + setSize, } = useTerminalBar(); const queryClient = useQueryClient(); const drawerStack = useDrawerStack(); const router = useRouter(); // ── Always-on WebSocket for alarm status + UI action dispatch ────── - // Lifted here so the connection persists regardless of which tab is active. - const handleAgentStatus = useCallback( (_event: AgentStatusEvent) => { void queryClient.invalidateQueries({ @@ -92,7 +111,6 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP }; const path = pageMap[action.page]; if (path) { - // Close any open drawers so they don't cover the new page drawerStack.closeAll(); router.push(path); } @@ -105,7 +123,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP break; } }, - [drawerStack, router, townId] + [drawerStack, router, townBasePath] ); const alarmWs = useAlarmStatusWs(townId, { @@ -114,6 +132,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP }); const sidebarLeft = isMobile ? '0px' : sidebarState === 'expanded' ? '16rem' : '3rem'; + const horizontal = isHorizontal(position); const allTabs = [ { id: 'status', label: 'Status', kind: 'status' as const, agentId: '' }, @@ -121,108 +140,542 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP ...agentTabs, ]; - // Default to mayor tab if nothing selected const effectiveActiveId = activeTabId ?? 'mayor'; const activeTab = allTabs.find(t => t.id === effectiveActiveId) ?? allTabs[0]; + // ── Resize drag logic ────────────────────────────────────────────── + const isDragging = useRef(false); + const startPos = useRef(0); + const startSize = useRef(0); + + const onResizePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + isDragging.current = true; + startSize.current = size; + startPos.current = horizontal ? e.clientY : e.clientX; + + const onPointerMove = (ev: PointerEvent) => { + if (!isDragging.current) return; + const currentPos = horizontal ? ev.clientY : ev.clientX; + // For bottom/right, dragging toward start of viewport increases size. + // For top/left, dragging away from start of viewport increases size. + const delta = + position === 'bottom' || position === 'right' + ? startPos.current - currentPos + : currentPos - startPos.current; + const newSize = clampSize(startSize.current + delta, position); + setSize(newSize); + }; + + const onPointerUp = () => { + isDragging.current = false; + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + }; + + document.body.style.userSelect = 'none'; + document.body.style.cursor = horizontal ? 'ns-resize' : 'ew-resize'; + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + }, + [size, position, horizontal, setSize] + ); + + // ── Compute container styles ─────────────────────────────────────── + const totalSize = collapsed ? COLLAPSED_SIZE : COLLAPSED_SIZE + size; + + const containerStyle = (() => { + const base: React.CSSProperties = { zIndex: 50 }; + + if (position === 'bottom') { + return { + ...base, + left: sidebarLeft, + right: 0, + bottom: 0, + height: totalSize, + }; + } + if (position === 'top') { + return { + ...base, + left: sidebarLeft, + right: 0, + top: 0, + height: totalSize, + }; + } + if (position === 'right') { + return { + ...base, + right: 0, + top: 0, + bottom: 0, + width: totalSize, + }; + } + // left + return { + ...base, + left: sidebarLeft, + top: 0, + bottom: 0, + width: totalSize, + }; + })(); + + // Border class depends on which edge faces content + const borderClass = { + bottom: 'border-t', + top: 'border-b', + right: 'border-l', + left: 'border-r', + }[position]; + + // Resize handle — rendered as a flex child so it naturally sits at the correct edge + // and doesn't compete with content stacking contexts for pointer events. + const isVerticalHandle = !horizontal; + const resizeHandleClass = [ + 'group/resize shrink-0 flex items-center justify-center', + isVerticalHandle ? 'h-full w-2 cursor-ew-resize' : 'w-full h-2 cursor-ns-resize', + ].join(' '); + const resizeHandleIndicator = isVerticalHandle + ? 'h-8 w-0.5 rounded-full bg-white/0 group-hover/resize:bg-white/25 transition-colors' + : 'w-8 h-0.5 rounded-full bg-white/0 group-hover/resize:bg-white/25 transition-colors'; + + // ── Collapse chevron direction ───────────────────────────────────── + const CollapseIcon = (() => { + if (collapsed) { + // Show icon pointing toward expansion + return { bottom: ChevronUp, top: ChevronDown, right: ChevronLeft, left: ChevronRight }[ + position + ]; + } + // Show icon pointing toward collapse + return { bottom: ChevronDown, top: ChevronUp, right: ChevronRight, left: ChevronLeft }[ + position + ]; + })(); + + // ── Layout direction ─────────────────────────────────────────────── + // Horizontal: tab bar is a row at top (bottom position) or bottom (top position), + // content fills remaining height. + // Vertical: tab bar is a column at top, content fills remaining width. + return (
+
+ {position === 'bottom' && ( + <> + {!collapsed && ( +
+
+
+ )} + + + + )} + {position === 'top' && ( + <> + + + {!collapsed && ( +
+
+
+ )} + + )} + {position === 'right' && ( + <> + {!collapsed && ( +
+
+
+ )} + + + + )} + {position === 'left' && ( + <> + + + {!collapsed && ( +
+
+
+ )} + + )} +
+
+ ); +} + +// ── Tab Bar ────────────────────────────────────────────────────────────── + +type TabDef = { + id: string; + label: string; + kind: 'mayor' | 'agent' | 'status'; + agentId: string; +}; + +function TabBar({ + allTabs, + effectiveActiveId, + collapsed, + horizontal, + position, + CollapseIcon, + setActiveTabId, + setCollapsed, + setPosition, + closeTab, +}: { + allTabs: TabDef[]; + effectiveActiveId: string; + collapsed: boolean; + horizontal: boolean; + position: TerminalPosition; + CollapseIcon: React.ComponentType<{ className?: string }>; + setActiveTabId: (id: string) => void; + setCollapsed: (collapsed: boolean) => void; + setPosition: (position: TerminalPosition) => void; + closeTab: (tabId: string) => void; +}) { + const [showPositionPicker, setShowPositionPicker] = useState(false); + const pickerRef = useRef(null); + + // Close picker on outside click + useEffect(() => { + if (!showPositionPicker) return; + const handler = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setShowPositionPicker(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showPositionPicker]); + + const borderClass = horizontal ? 'border-b border-white/[0.06]' : 'border-r border-white/[0.06]'; + + return ( +
- {/* Tab bar */} + {/* Collapse toggle */} + + + {/* Tabs */}
- {/* Collapse toggle */} + + {allTabs.map(tab => { + const isActive = tab.id === effectiveActiveId; + const isMayor = tab.kind === 'mayor'; + + return ( + { + setActiveTabId(tab.id); + if (collapsed) setCollapsed(false); + }} + className={`group flex cursor-pointer items-center whitespace-nowrap transition-colors ${ + horizontal + ? `gap-1.5 overflow-hidden rounded-t-md px-3 py-1 text-[11px]` + : `relative justify-center overflow-visible rounded-md px-1 py-2` + } ${ + isActive + ? 'bg-white/[0.06] text-white/80' + : 'text-white/35 hover:bg-white/[0.03] hover:text-white/55' + }`} + title={horizontal ? undefined : tab.label} + > + {isMayor && ( + + )} + {tab.kind === 'status' && ( + + )} + {tab.kind === 'agent' && !horizontal && ( + + )} + {horizontal && {tab.label}} + {!isMayor && tab.kind !== 'status' && ( + + )} + + ); + })} + +
+ + {/* Position picker */} +
- - {/* Tabs */} -
- - {allTabs.map(tab => { - const isActive = tab.id === effectiveActiveId; - const isMayor = tab.kind === 'mayor'; - - return ( - { - setActiveTabId(tab.id); - if (collapsed) setCollapsed(false); - }} - className={`group flex cursor-pointer items-center gap-1.5 overflow-hidden rounded-t-md px-3 py-1 text-[11px] whitespace-nowrap transition-colors ${ - isActive - ? 'bg-white/[0.06] text-white/80' - : 'text-white/35 hover:bg-white/[0.03] hover:text-white/55' - }`} - > - {isMayor && ( - - )} - {tab.kind === 'status' && ( - - )} - {tab.label} - {!isMayor && tab.kind !== 'status' && ( - - )} - - ); - })} - -
+ {showPositionPicker && ( + { + setPosition(p); + setShowPositionPicker(false); + }} + position={position} + triggerRef={pickerRef} + /> + )}
+
+ ); +} + +// ── Position Picker Popup ──────────────────────────────────────────────── + +const POSITION_OPTIONS: { value: TerminalPosition; label: string; Icon: typeof PanelBottom }[] = [ + { value: 'bottom', label: 'Bottom', Icon: PanelBottom }, + { value: 'top', label: 'Top', Icon: PanelTop }, + { value: 'left', label: 'Left', Icon: PanelLeft }, + { value: 'right', label: 'Right', Icon: PanelRight }, +]; + +function PositionPicker({ + current, + onSelect, + position, + triggerRef, +}: { + current: TerminalPosition; + onSelect: (p: TerminalPosition) => void; + position: TerminalPosition; + triggerRef: React.RefObject; +}) { + const popoverRef = useRef(null); + const [style, setStyle] = useState({ opacity: 0 }); + + useEffect(() => { + const trigger = triggerRef.current; + const popover = popoverRef.current; + if (!trigger || !popover) return; + const tr = trigger.getBoundingClientRect(); + const pr = popover.getBoundingClientRect(); + const gap = 4; + + let top: number; + let left: number; + + if (position === 'bottom') { + top = tr.top - pr.height - gap; + left = tr.right - pr.width; + } else if (position === 'top') { + top = tr.bottom + gap; + left = tr.right - pr.width; + } else if (position === 'left') { + top = tr.top; + left = tr.right + gap; + } else { + // right + top = tr.top; + left = tr.left - pr.width - gap; + } + + // Clamp to viewport + top = Math.max(4, Math.min(top, window.innerHeight - pr.height - 4)); + left = Math.max(4, Math.min(left, window.innerWidth - pr.width - 4)); - {/* Terminal content area */} - - {!collapsed && activeTab && ( - +
+ {POSITION_OPTIONS.map(({ value, label, Icon }) => ( + + ))} +
); } +// ── Terminal Content Area ───────────────────────────────────────────────── + +function TerminalContent({ + activeTab, + collapsed, + horizontal, + size, + townId, + alarmWs, +}: { + activeTab: TabDef; + collapsed: boolean; + horizontal: boolean; + size: number; + townId: string; + alarmWs: AlarmWsResult; +}) { + if (collapsed) return null; + + return ( + + + {activeTab.kind === 'mayor' ? ( + + ) : activeTab.kind === 'status' ? ( + + ) : ( + + )} + + + ); +} + // ── Alarm Status Pane ──────────────────────────────────────────────────── type AlarmStatus = { @@ -315,16 +768,10 @@ function useAlarmStatusWs( const msg = parsed as Record; if (msg.type === 'agent_status') { - // Lightweight agent_status event — dispatch to callback, don't - // overwrite the alarm status snapshot. onAgentStatusRef.current?.(parsed as AgentStatusEvent); } else if (msg.channel === 'ui_action') { - // UI action from the mayor — dispatch to callback for DrawerStack/router. onUiActionRef.current?.(parsed as UiActionEvent); } else if ('alarm' in msg) { - // Only alarm snapshots have an `alarm` field. Bead, convoy, - // and other channel frames are silently ignored here to avoid - // overwriting the status data with the wrong shape. setData(parsed as AlarmStatus); } } catch { @@ -335,7 +782,6 @@ function useAlarmStatusWs( ws.onclose = () => { if (!mountedRef.current) return; setConnected(false); - // Reconnect after 3s reconnectTimerRef.current = setTimeout(connect, 3_000); }; @@ -365,14 +811,19 @@ type AlarmWsResult = { error: string | null; }; -function AlarmStatusPane({ townId, alarmWs }: { townId: string; alarmWs: AlarmWsResult }) { +function AlarmStatusPane({ + townId, + alarmWs, + horizontal, +}: { + townId: string; + alarmWs: AlarmWsResult; + horizontal: boolean; +}) { const trpc = useGastownTRPC(); const { data: wsData, connected: wsConnected, error: wsError } = alarmWs; - // Fall back to polling when WebSocket is unavailable (blocked, errored, - // or never connected). The tRPC query is disabled while the WS is - // providing data to avoid redundant requests. const wsFailed = !!wsError && !wsData; const pollingQuery = useQuery({ ...trpc.gastown.getAlarmStatus.queryOptions({ townId }), @@ -404,144 +855,175 @@ function AlarmStatusPane({ townId, alarmWs }: { townId: string; alarmWs: AlarmWs data.patrol.stalledAgents > 0 || data.patrol.orphanedHooks > 0; + // Vertical orientation: single-column stacked layout + if (!horizontal) { + return ( +
+ + + +
+ ); + } + + // Horizontal: two-column layout return (
- {/* Connection indicator */} -
- - - {wsConnected ? 'Live' : wsFailed ? 'Polling' : 'Reconnecting...'} - -
+ {/* Left column: status cards */}
- {/* Alarm */} -
-
- - Alarm Loop -
-
- - -
-
+ +
- {/* Agents */} -
-
- Agents ({data.agents.total}) -
-
- 0} - /> - - 0} /> - 0} /> -
+ {/* Right column: event feed */} + +
+ ); +} + +function ConnectionIndicator({ connected, failed }: { connected: boolean; failed: boolean }) { + return ( +
+ + + {connected ? 'Live' : failed ? 'Polling' : 'Reconnecting...'} + +
+ ); +} + +function StatusCards({ data, hasIssues }: { data: AlarmStatus; hasIssues: boolean }) { + return ( + <> + {/* Alarm */} +
+
+ + Alarm Loop +
+
+ +
+
- {/* Beads */} -
-
- Beads -
-
- - 0} - /> - 0} - /> - 0} /> - 0} - /> -
+ {/* Agents */} +
+
+ Agents ({data.agents.total}) +
+
+ 0} + /> + + 0} /> + 0} />
+
- {/* Patrol */} -
-
- Patrol {hasIssues ? '(issues detected)' : ''} -
-
- 0} - /> - 0} - /> - 0} - /> - 0} - /> -
+ {/* Beads */} +
+
+ Beads +
+
+ + 0} + /> + 0} + /> + 0} /> + 0} + />
- {/* Right column: event feed */} -
-
- Recent Events + {/* Patrol */} +
+
+ Patrol {hasIssues ? '(issues detected)' : ''}
-
- {data.recentEvents.length === 0 ? ( -
- No recent events -
- ) : ( -
- {data.recentEvents.map((event, i) => ( -
- - {formatTime(event.time)} - - - {event.type} - - {event.message} -
- ))} -
- )} +
+ 0} + /> + 0} + /> + 0} + /> + 0} + />
+ + ); +} + +function EventFeed({ events }: { events: Array<{ time: string; type: string; message: string }> }) { + return ( +
+
+ Recent Events +
+
+ {events.length === 0 ? ( +
+ No recent events +
+ ) : ( +
+ {events.map((event, i) => ( +
+ + {formatTime(event.time)} + + + {event.type} + + {event.message} +
+ ))} +
+ )} +
); } @@ -675,13 +1157,14 @@ function MayorTerminalPane({ townId, collapsed }: { townId: string; collapsed: b }); const { state: sidebarState } = useSidebar(); + const { position, size } = useTerminalBar(); - // Re-fit terminal when expanding or sidebar changes + // Re-fit terminal when expanding, sidebar changes, or size/position changes useEffect(() => { if (collapsed || !fitAddonRef.current) return; const t = setTimeout(() => fitAddonRef.current?.fit(), 50); return () => clearTimeout(t); - }, [collapsed, sidebarState]); + }, [collapsed, sidebarState, position, size]); return (
diff --git a/src/components/gastown/TerminalBarContext.tsx b/src/components/gastown/TerminalBarContext.tsx index 0a4a3194f1..3c4235b645 100644 --- a/src/components/gastown/TerminalBarContext.tsx +++ b/src/components/gastown/TerminalBarContext.tsx @@ -1,6 +1,8 @@ 'use client'; -import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; + +export type TerminalPosition = 'bottom' | 'top' | 'right' | 'left'; type TerminalTab = { id: string; @@ -9,14 +11,65 @@ type TerminalTab = { agentId: string; }; +const COLLAPSED_SIZE = 38; +const DEFAULT_EXPANDED_SIZE = 300; +const MIN_SIZE_HORIZONTAL = 100; +const MAX_SIZE_HORIZONTAL_RATIO = 0.7; +const MIN_SIZE_VERTICAL = 200; +const MAX_SIZE_VERTICAL_RATIO = 0.5; + +const LS_KEY_POSITION = 'gastown-terminal-position'; +const LS_KEY_SIZE = 'gastown-terminal-size'; + +export { COLLAPSED_SIZE, DEFAULT_EXPANDED_SIZE }; + +function isHorizontal(p: TerminalPosition) { + return p === 'bottom' || p === 'top'; +} + +export { isHorizontal }; + +function readStoredPosition(): TerminalPosition { + if (typeof window === 'undefined') return 'bottom'; + const stored = localStorage.getItem(LS_KEY_POSITION); + if (stored === 'bottom' || stored === 'top' || stored === 'right' || stored === 'left') { + return stored; + } + return 'bottom'; +} + +function readStoredSize(): number { + if (typeof window === 'undefined') return DEFAULT_EXPANDED_SIZE; + const stored = localStorage.getItem(LS_KEY_SIZE); + if (stored) { + const n = parseInt(stored, 10); + if (!isNaN(n) && n >= MIN_SIZE_HORIZONTAL) return n; + } + return DEFAULT_EXPANDED_SIZE; +} + +export function clampSize(size: number, position: TerminalPosition): number { + if (isHorizontal(position)) { + const max = + typeof window !== 'undefined' ? window.innerHeight * MAX_SIZE_HORIZONTAL_RATIO : 600; + return Math.max(MIN_SIZE_HORIZONTAL, Math.min(size, max)); + } + const max = typeof window !== 'undefined' ? window.innerWidth * MAX_SIZE_VERTICAL_RATIO : 800; + return Math.max(MIN_SIZE_VERTICAL, Math.min(size, max)); +} + type TerminalBarContextValue = { tabs: TerminalTab[]; activeTabId: string | null; collapsed: boolean; + position: TerminalPosition; + size: number; openAgentTab: (agentId: string, agentName: string) => void; closeTab: (tabId: string) => void; setActiveTabId: (id: string) => void; setCollapsed: (collapsed: boolean) => void; + setPosition: (position: TerminalPosition) => void; + setSize: (size: number) => void; }; const TerminalBarContext = createContext(null); @@ -31,6 +84,34 @@ export function TerminalBarProvider({ children }: { children: ReactNode }) { const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); const [collapsed, setCollapsed] = useState(false); + const [position, setPositionState] = useState('bottom'); + const [size, setSizeState] = useState(DEFAULT_EXPANDED_SIZE); + + // Hydrate from localStorage on mount + useEffect(() => { + setPositionState(readStoredPosition()); + setSizeState(readStoredSize()); + }, []); + + const setPosition = useCallback((p: TerminalPosition) => { + setPositionState(p); + localStorage.setItem(LS_KEY_POSITION, p); + // Re-clamp size for the new orientation's constraints + setSizeState(prev => { + const clamped = clampSize(prev, p); + localStorage.setItem(LS_KEY_SIZE, String(clamped)); + return clamped; + }); + }, []); + + const setSize = useCallback( + (s: number, pos?: TerminalPosition) => { + const val = clampSize(s, pos ?? position); + setSizeState(val); + localStorage.setItem(LS_KEY_SIZE, String(val)); + }, + [position] + ); const openAgentTab = useCallback((agentId: string, agentName: string) => { const tabId = `agent:${agentId}`; @@ -56,7 +137,19 @@ export function TerminalBarProvider({ children }: { children: ReactNode }) { return ( {children} diff --git a/src/components/gastown/TerminalBarPadding.tsx b/src/components/gastown/TerminalBarPadding.tsx new file mode 100644 index 0000000000..f3097f3461 --- /dev/null +++ b/src/components/gastown/TerminalBarPadding.tsx @@ -0,0 +1,37 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useTerminalBar, COLLAPSED_SIZE, isHorizontal } from './TerminalBarContext'; + +/** + * Client component that wraps page content and applies dynamic padding + * to clear the fixed terminal bar. Replaces the static `pb-[340px]` + * in layouts with position/size/collapse-aware padding. + */ +export function TerminalBarPadding({ children }: { children: ReactNode }) { + const { position, size, collapsed } = useTerminalBar(); + + const totalSize = collapsed ? COLLAPSED_SIZE : COLLAPSED_SIZE + size; + + const style: React.CSSProperties = {}; + + if (isHorizontal(position)) { + if (position === 'bottom') { + style.paddingBottom = `${totalSize}px`; + } else { + style.paddingTop = `${totalSize}px`; + } + } else { + if (position === 'right') { + style.paddingRight = `${totalSize}px`; + } else { + style.paddingLeft = `${totalSize}px`; + } + } + + return ( +
+
{children}
+
+ ); +}