diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 0cf7c974a..0914525ca 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -1722,6 +1722,14 @@ export class TownDO extends DurableObject { return reviewQueue.advanceMoleculeStep(this.sql, agentId, summary); } + async getMergeQueueData(params: { + rigId?: string; + limit?: number; + since?: string; + }): Promise { + return reviewQueue.getMergeQueueData(this.sql, params); + } + // ══════════════════════════════════════════════════════════════════ // Atomic Sling (create bead + agent + hook) // ══════════════════════════════════════════════════════════════════ diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index 5b746cb6c..645561803 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -12,6 +12,7 @@ import { review_metadata } from '../../db/tables/review-metadata.table'; import { bead_dependencies } from '../../db/tables/bead-dependencies.table'; import { agent_metadata } from '../../db/tables/agent-metadata.table'; import { convoy_metadata } from '../../db/tables/convoy-metadata.table'; +import { bead_events } from '../../db/tables/bead-events.table'; import { query } from '../../util/query.util'; import { logBeadEvent, @@ -744,6 +745,536 @@ export function agentCompleted( return result; } +// ── Merge Queue Data ──────────────────────────────────────────────── + +/** + * 24 hours in milliseconds — MR beads in_review longer than this are "stale". + */ +const STALE_PR_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +/** Zod schema for a single enriched MR bead row from the needsAttention query. */ +const MrBeadRow = z.object({ + bead_id: z.string(), + status: z.string(), + title: z.string(), + body: z.string().nullable(), + rig_id: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + metadata: z.string().transform((v): Record => { + try { + return JSON.parse(v) as Record; + } catch { + return {}; + } + }), + // review_metadata columns + branch: z.string(), + target_branch: z.string(), + merge_commit: z.string().nullable(), + pr_url: z.string().nullable(), + retry_count: z.number(), + // source bead (via bead_dependencies tracks) + source_bead_id: z.string().nullable(), + source_bead_title: z.string().nullable(), + source_bead_status: z.string().nullable(), + source_bead_body: z.string().nullable(), + // convoy info (via metadata.convoy_id → convoy_metadata) + convoy_id: z.string().nullable(), + convoy_title: z.string().nullable(), + convoy_total_beads: z.number().nullable(), + convoy_closed_beads: z.number().nullable(), + convoy_feature_branch: z.string().nullable(), + convoy_merge_mode: z.string().nullable(), + // agent info (via metadata.source_agent_id → agent_metadata) + agent_id: z.string().nullable(), + agent_name: z.string().nullable(), + 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. */ +const ActivityLogRow = z.object({ + bead_event_id: z.string(), + bead_id: z.string(), + agent_id: z.string().nullable(), + event_type: z.string(), + old_value: z.string().nullable(), + new_value: z.string().nullable(), + event_metadata: z.string().transform((v): Record => { + try { + return JSON.parse(v) as Record; + } catch { + return {}; + } + }), + event_created_at: z.string(), + // associated bead info + bead_title: z.string().nullable(), + bead_type: z.string().nullable(), + bead_status: z.string().nullable(), + bead_rig_id: z.string().nullable(), + bead_metadata: z + .string() + .nullable() + .transform((v): Record => { + try { + return v ? (JSON.parse(v) as Record) : {}; + } catch { + return {}; + } + }), + // agent info + agent_name: z.string().nullable(), + 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(), + rm_merge_commit: z.string().nullable(), + rm_pr_url: z.string().nullable(), + // convoy info + convoy_id: z.string().nullable(), + convoy_title: z.string().nullable(), + convoy_total_beads: z.number().nullable(), + convoy_closed_beads: z.number().nullable(), + convoy_feature_branch: z.string().nullable(), + convoy_merge_mode: z.string().nullable(), +}); + +export type MergeQueueParams = { + rigId?: string; + limit?: number; + since?: string; +}; + +export type MergeQueueData = { + needsAttention: { + openPRs: MergeQueueItem[]; + failedReviews: MergeQueueItem[]; + stalePRs: MergeQueueItem[]; + }; + activityLog: ActivityLogEntry[]; +}; + +export type MergeQueueItem = { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; +}; + +export type ActivityLogEntry = { + event: { + bead_event_id: string; + bead_id: string; + agent_id: string | null; + event_type: string; + old_value: string | null; + new_value: string | null; + metadata: Record; + created_at: string; + }; + mrBead: { + bead_id: string; + title: string; + type: string; + status: string; + rig_id: string | null; + metadata: Record; + } | null; + sourceBead: { + bead_id: string; + title: string; + status: string; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + reviewMetadata: { + pr_url: string | null; + branch: string | null; + target_branch: string | null; + merge_commit: string | null; + } | null; +}; + +/** + * Query all data the Merge Queue page needs: MR beads needing attention + * (open PRs, failed reviews, stale PRs) and a recent activity log. + */ +export function getMergeQueueData(sql: SqlStorage, params: MergeQueueParams): MergeQueueData { + const rigId = params.rigId ?? null; + + // ── 1. Query MR beads with full joins ─────────────────────────────── + // Statuses: in_progress = "in review" (PR created, awaiting merge), + // open = pending review, failed = review failed + // We fetch all non-closed MR beads for the needs-attention section. + const mrRows = [ + ...query( + sql, + /* sql */ ` + SELECT + ${beads.bead_id}, + ${beads.status}, + ${beads.title}, + ${beads.body}, + ${beads.rig_id}, + ${beads.created_at}, + ${beads.updated_at}, + ${beads.metadata}, + ${review_metadata.branch}, + ${review_metadata.target_branch}, + ${review_metadata.merge_commit}, + ${review_metadata.pr_url}, + ${review_metadata.retry_count}, + 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, + src.${beads.columns.body} AS source_bead_body, + cm.${convoy_metadata.columns.bead_id} AS convoy_id, + convoy_bead.${beads.columns.title} AS convoy_title, + cm.${convoy_metadata.columns.total_beads} AS convoy_total_beads, + cm.${convoy_metadata.columns.closed_beads} AS convoy_closed_beads, + cm.${convoy_metadata.columns.feature_branch} AS convoy_feature_branch, + cm.${convoy_metadata.columns.merge_mode} AS convoy_merge_mode, + 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, + (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} + LEFT JOIN ${bead_dependencies} AS dep + ON dep.${bead_dependencies.columns.bead_id} = ${beads.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 ${convoy_metadata} AS cm + ON cm.${convoy_metadata.columns.bead_id} = json_extract(${beads.metadata}, '$.convoy_id') + LEFT JOIN ${beads} AS convoy_bead + ON convoy_bead.${beads.columns.bead_id} = cm.${convoy_metadata.columns.bead_id} + LEFT JOIN ${agent_metadata} AS am + ON am.${agent_metadata.columns.bead_id} = json_extract(${beads.metadata}, '$.source_agent_id') + LEFT JOIN ${beads} AS agent_bead + ON agent_bead.${beads.columns.bead_id} = am.${agent_metadata.columns.bead_id} + LEFT JOIN rigs AS rig + ON rig.id = ${beads.rig_id} + WHERE ${beads.type} = 'merge_request' + AND ${beads.status} IN ('open', 'in_progress', 'in_review', 'failed') + AND (? IS NULL OR ${beads.rig_id} = ?) + ORDER BY ${beads.created_at} DESC + `, + [rigId, rigId] + ), + ]; + + const parsedMrRows = MrBeadRow.array().parse(mrRows); + const staleThreshold = new Date(Date.now() - STALE_PR_THRESHOLD_MS).toISOString(); + + const openPRs: MergeQueueItem[] = []; + const failedReviews: MergeQueueItem[] = []; + const stalePRs: MergeQueueItem[] = []; + + for (const row of parsedMrRows) { + const item = mrBeadRowToItem(row); + + if (row.status === 'failed') { + failedReviews.push(item); + } else if (row.pr_url && row.status === 'in_progress') { + // in_progress with pr_url = PR created, awaiting human merge + if (row.updated_at < staleThreshold) { + item.staleSince = row.updated_at; + stalePRs.push(item); + } else { + openPRs.push(item); + } + } + // open/in_review without pr_url are pending queue items, not shown in needs-attention + } + + // ── 2. Query activity log events ──────────────────────────────────── + const limit = params.limit ?? 50; + const since = params.since ?? null; + + const eventRows = [ + ...query( + sql, + /* sql */ ` + SELECT + ${bead_events.bead_event_id}, + ${bead_events.bead_id}, + ${bead_events.agent_id}, + ${bead_events.event_type}, + ${bead_events.old_value}, + ${bead_events.new_value}, + ${bead_events.metadata} AS event_metadata, + ${bead_events.created_at} AS event_created_at, + b.${beads.columns.title} AS bead_title, + b.${beads.columns.type} AS bead_type, + b.${beads.columns.status} AS bead_status, + b.${beads.columns.rig_id} AS bead_rig_id, + b.${beads.columns.metadata} AS bead_metadata, + 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, + rm.${review_metadata.columns.pr_url} AS rm_pr_url, + cm.${convoy_metadata.columns.bead_id} AS convoy_id, + convoy_bead.${beads.columns.title} AS convoy_title, + cm.${convoy_metadata.columns.total_beads} AS convoy_total_beads, + cm.${convoy_metadata.columns.closed_beads} AS convoy_closed_beads, + cm.${convoy_metadata.columns.feature_branch} AS convoy_feature_branch, + cm.${convoy_metadata.columns.merge_mode} AS convoy_merge_mode + FROM ${bead_events} + INNER JOIN ${beads} AS b + ON b.${beads.columns.bead_id} = ${bead_events.bead_id} + LEFT JOIN ${agent_metadata} AS am + 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 + ON cm.${convoy_metadata.columns.bead_id} = json_extract(b.${beads.columns.metadata}, '$.convoy_id') + LEFT JOIN ${beads} AS convoy_bead + ON convoy_bead.${beads.columns.bead_id} = cm.${convoy_metadata.columns.bead_id} + LEFT JOIN rigs AS rig + ON rig.id = b.${beads.columns.rig_id} + WHERE ${bead_events.event_type} IN ( + 'review_submitted', 'review_completed', 'pr_created', + 'pr_creation_failed', 'rework_requested', 'status_changed' + ) + AND (? IS NULL OR b.${beads.columns.rig_id} = ?) + AND (? IS NULL OR ${bead_events.created_at} > ?) + ORDER BY ${bead_events.created_at} DESC + LIMIT ? + `, + [rigId, rigId, since, since, limit] + ), + ]; + + const parsedEventRows = ActivityLogRow.array().parse(eventRows); + + const activityLog: ActivityLogEntry[] = parsedEventRows.map(eventRowToEntry); + + return { + needsAttention: { openPRs, failedReviews, stalePRs }, + activityLog, + }; +} + +function mrBeadRowToItem(row: z.output): MergeQueueItem { + return { + mrBead: { + bead_id: row.bead_id, + status: row.status, + title: row.title, + body: row.body, + rig_id: row.rig_id, + created_at: row.created_at, + updated_at: row.updated_at, + metadata: row.metadata, + }, + reviewMetadata: { + branch: row.branch, + target_branch: row.target_branch, + merge_commit: row.merge_commit, + pr_url: row.pr_url, + retry_count: row.retry_count, + }, + sourceBead: row.source_bead_id + ? { + bead_id: row.source_bead_id, + title: row.source_bead_title ?? '', + status: row.source_bead_status ?? '', + body: row.source_bead_body ?? null, + } + : null, + convoy: row.convoy_id + ? { + convoy_id: row.convoy_id, + title: row.convoy_title ?? '', + total_beads: row.convoy_total_beads ?? 0, + closed_beads: row.convoy_closed_beads ?? 0, + feature_branch: row.convoy_feature_branch, + merge_mode: row.convoy_merge_mode, + } + : null, + agent: row.agent_id + ? { + agent_id: row.agent_id, + name: row.agent_name ?? '', + role: row.agent_role ?? '', + } + : null, + rigName: row.rig_name, + staleSince: 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 { + // 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: { + bead_event_id: row.bead_event_id, + bead_id: row.bead_id, + agent_id: row.agent_id, + event_type: row.event_type, + old_value: row.old_value, + new_value: row.new_value, + metadata: row.event_metadata, + created_at: row.event_created_at, + }, + mrBead: row.bead_title + ? { + bead_id: row.bead_id, + title: row.bead_title, + type: row.bead_type ?? 'merge_request', + status: row.bead_status ?? '', + rig_id: row.bead_rig_id, + metadata: row.bead_metadata, + } + : null, + sourceBead: resolvedSourceBead, + convoy: row.convoy_id + ? { + convoy_id: row.convoy_id, + title: row.convoy_title ?? '', + total_beads: row.convoy_total_beads ?? 0, + closed_beads: row.convoy_closed_beads ?? 0, + feature_branch: row.convoy_feature_branch, + merge_mode: row.convoy_merge_mode, + } + : null, + agent: row.agent_id + ? { + agent_id: row.agent_id, + name: row.agent_name ?? '', + role: row.agent_role ?? '', + } + : null, + rigName: row.rig_name, + reviewMetadata: + row.rm_branch !== null + ? { + pr_url: row.rm_pr_url, + branch: row.rm_branch, + target_branch: row.rm_target_branch, + merge_commit: row.rm_merge_commit, + } + : null, + }; +} + // ── Molecules ─────────────────────────────────────────────────────── /** diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 6d88f3442..c76afd093 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -33,6 +33,7 @@ import { RpcConvoyDetailOutput, RpcAlarmStatusOutput, RpcOrgTownOutput, + RpcMergeQueueDataOutput, } from './schemas'; import type { TRPCContext } from './init'; @@ -898,6 +899,26 @@ export const gastownRouter = router({ }); }), + getMergeQueueData: gastownProcedure + .input( + z.object({ + townId: z.string().uuid(), + rigId: z.string().uuid().optional(), + limit: z.number().int().positive().max(500).default(50), + since: z.string().optional(), + }) + ) + .output(RpcMergeQueueDataOutput) + .query(async ({ ctx, input }) => { + await verifyTownOwnership(ctx.env, ctx.userId, input.townId, ctx.orgMemberships); + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.getMergeQueueData({ + rigId: input.rigId, + limit: input.limit, + since: input.since, + }); + }), + listConvoys: gastownProcedure .input( z.object({ diff --git a/cloudflare-gastown/src/trpc/schemas.ts b/cloudflare-gastown/src/trpc/schemas.ts index 65067ec78..85dfa963d 100644 --- a/cloudflare-gastown/src/trpc/schemas.ts +++ b/cloudflare-gastown/src/trpc/schemas.ts @@ -226,6 +226,114 @@ const AlarmStatusOutput = z.object({ export const RpcAlarmStatusOutput = rpcSafe(AlarmStatusOutput); export const RpcRigDetailOutput = rpcSafe(RigDetailOutput); +// ── Merge Queue ────────────────────────────────────────────────────── + +const MergeQueueBeadOutput = z.object({ + bead_id: z.string(), + status: z.string(), + title: z.string(), + body: z.string().nullable(), + rig_id: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + metadata: z.record(z.string(), z.unknown()), +}); + +const ReviewMetadataOutput = z.object({ + branch: z.string(), + target_branch: z.string(), + merge_commit: z.string().nullable(), + pr_url: z.string().nullable(), + retry_count: z.number(), +}); + +const SourceBeadOutput = z.object({ + bead_id: z.string(), + title: z.string(), + status: z.string(), + body: z.string().nullable(), +}); + +const ConvoyRefOutput = z.object({ + convoy_id: z.string(), + title: z.string(), + total_beads: z.number(), + closed_beads: z.number(), + feature_branch: z.string().nullable(), + merge_mode: z.string().nullable(), +}); + +const AgentRefOutput = z.object({ + agent_id: z.string(), + name: z.string(), + role: z.string(), +}); + +const MergeQueueItemOutput = z.object({ + mrBead: MergeQueueBeadOutput, + reviewMetadata: ReviewMetadataOutput, + sourceBead: SourceBeadOutput.nullable(), + convoy: ConvoyRefOutput.nullable(), + agent: AgentRefOutput.nullable(), + rigName: z.string().nullable(), + staleSince: z.string().nullable(), + failureReason: z.string().nullable(), +}); + +const ActivityLogEventOutput = z.object({ + bead_event_id: z.string(), + bead_id: z.string(), + agent_id: z.string().nullable(), + event_type: z.string(), + old_value: z.string().nullable(), + new_value: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()), + created_at: z.string(), +}); + +const ActivityLogMrBeadOutput = z.object({ + bead_id: z.string(), + title: z.string(), + type: z.string(), + status: z.string(), + rig_id: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()), +}); + +const ActivityLogReviewMetadataOutput = z.object({ + pr_url: z.string().nullable(), + branch: z.string().nullable(), + target_branch: z.string().nullable(), + merge_commit: z.string().nullable(), +}); + +const ActivityLogSourceBeadOutput = z.object({ + bead_id: z.string(), + title: z.string(), + status: z.string(), +}); + +const ActivityLogEntryOutput = z.object({ + event: ActivityLogEventOutput, + mrBead: ActivityLogMrBeadOutput.nullable(), + sourceBead: ActivityLogSourceBeadOutput.nullable(), + convoy: ConvoyRefOutput.nullable(), + agent: AgentRefOutput.nullable(), + rigName: z.string().nullable(), + reviewMetadata: ActivityLogReviewMetadataOutput.nullable(), +}); + +export const MergeQueueDataOutput = z.object({ + needsAttention: z.object({ + openPRs: z.array(MergeQueueItemOutput), + failedReviews: z.array(MergeQueueItemOutput), + stalePRs: z.array(MergeQueueItemOutput), + }), + activityLog: z.array(ActivityLogEntryOutput), +}); + +export const RpcMergeQueueDataOutput = rpcSafe(MergeQueueDataOutput); + // OrgTown (from GastownOrgDO) export const OrgTownOutput = z.object({ id: z.string(), diff --git a/src/app/(app)/gastown/[townId]/layout.tsx b/src/app/(app)/gastown/[townId]/layout.tsx index f473c173b..38124aeab 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 0b515a348..ff9186841 100644 --- a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx @@ -1,73 +1,137 @@ 'use client'; +import { useState } from 'react'; 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, 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 eventsQuery = useQuery({ - ...trpc.gastown.getTownEvents.queryOptions({ townId, limit: 200 }), + const mergeQueueQuery = useQuery({ + ...trpc.gastown.getMergeQueueData.queryOptions({ + townId, + rigId: rigIdParam, + limit: 200, + }), 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} + + )} +
+ + {/* Rig filter */} +
+ +
+ {/* 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 ? ( - - ) : ( - + {/* Needs Your Attention section */} + {needsAttention && ( +
+
+ +

+ Needs Your Attention +

+ {totalAttention > 0 && ( + + {totalAttention} + )} -
-
- {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 })} - -
-
- ); - })} + +
+ )} + + {/* Refinery Activity Log section */} + {mergeQueueQuery.data && ( +
+
+ +

+ Refinery Activity Log +

+
+ +
+ )} +
); 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 000000000..bb47bb5a2 --- /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)/gastown/[townId]/merges/RefineryActivityLog.tsx b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx new file mode 100644 index 000000000..8bc905c50 --- /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 + + )} +
+
+ + ); +} diff --git a/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx b/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx index 65fa27910..e472866a0 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 e97ddbf12..eed3b366a 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 dd525af4b..0e826331d 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 0a4a3194f..3c4235b64 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 000000000..f3097f346 --- /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}
+
+ ); +} diff --git a/src/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index 3f616b1f9..591ea8bef 100644 --- a/src/lib/gastown/types/router.d.ts +++ b/src/lib/gastown/types/router.d.ts @@ -580,6 +580,187 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter< }[]; meta: object; }>; + getMergeQueueData: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + rigId?: string | undefined; + limit?: number | undefined; + since?: string | undefined; + }; + output: { + needsAttention: { + openPRs: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + failedReviews: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + stalePRs: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + }; + activityLog: { + event: { + bead_event_id: string; + bead_id: string; + agent_id: string | null; + event_type: string; + old_value: string | null; + new_value: string | null; + metadata: Record; + created_at: string; + }; + mrBead: { + bead_id: string; + title: string; + type: string; + status: string; + rig_id: string | null; + metadata: Record; + } | null; + sourceBead: { + bead_id: string; + title: string; + status: string; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + reviewMetadata: { + pr_url: string | null; + branch: string | null; + target_branch: string | null; + merge_commit: string | null; + } | null; + }[]; + }; + meta: object; + }>; listConvoys: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string; @@ -1605,6 +1786,187 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute }[]; meta: object; }>; + getMergeQueueData: import('@trpc/server').TRPCQueryProcedure<{ + input: { + townId: string; + rigId?: string | undefined; + limit?: number | undefined; + since?: string | undefined; + }; + output: { + needsAttention: { + openPRs: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + failedReviews: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + stalePRs: { + mrBead: { + bead_id: string; + status: string; + title: string; + body: string | null; + rig_id: string | null; + created_at: string; + updated_at: string; + metadata: Record; + }; + reviewMetadata: { + branch: string; + target_branch: string; + merge_commit: string | null; + pr_url: string | null; + retry_count: number; + }; + sourceBead: { + bead_id: string; + title: string; + status: string; + body: string | null; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + staleSince: string | null; + failureReason: string | null; + }[]; + }; + activityLog: { + event: { + bead_event_id: string; + bead_id: string; + agent_id: string | null; + event_type: string; + old_value: string | null; + new_value: string | null; + metadata: Record; + created_at: string; + }; + mrBead: { + bead_id: string; + title: string; + type: string; + status: string; + rig_id: string | null; + metadata: Record; + } | null; + sourceBead: { + bead_id: string; + title: string; + status: string; + } | null; + convoy: { + convoy_id: string; + title: string; + total_beads: number; + closed_beads: number; + feature_branch: string | null; + merge_mode: string | null; + } | null; + agent: { + agent_id: string; + name: string; + role: string; + } | null; + rigName: string | null; + reviewMetadata: { + pr_url: string | null; + branch: string | null; + target_branch: string | null; + merge_commit: string | null; + } | null; + }[]; + }; + meta: object; + }>; listConvoys: import('@trpc/server').TRPCQueryProcedure<{ input: { townId: string;