From 77f44efa45260f87175e1705a47e19c0b480e2e6 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 03:53:21 +0000 Subject: [PATCH 1/5] feat(gastown): add getMergeQueueData tRPC procedure for merge queue page Add a dedicated tRPC query that returns structured merge queue data: - needsAttention section with openPRs, failedReviews, and stalePRs - activityLog with enriched review-related bead events - Full JOINs to review_metadata, source beads, convoy_metadata, agents, rigs - Input params: townId (required), rigId, limit, since for filtering/polling - Zod schemas with rpcSafe wrappers and type declarations for frontend --- cloudflare-gastown/src/dos/Town.do.ts | 8 + .../src/dos/town/review-queue.ts | 486 ++++++++++++++++++ cloudflare-gastown/src/trpc/router.ts | 21 + cloudflare-gastown/src/trpc/schemas.ts | 108 ++++ src/lib/gastown/types/router.d.ts | 362 +++++++++++++ 5 files changed, 985 insertions(+) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 0cf7c974a7..0914525ca0 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 5b746cb6c4..30fdbfa090 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,491 @@ 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(), +}); + +/** 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(), + // 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 + 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, + 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 ${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: 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; + + 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: sourceBeadId + ? { + bead_id: sourceBeadId, + title: + typeof row.bead_metadata?.source_bead_title === 'string' + ? row.bead_metadata.source_bead_title + : '', + status: '', + } + : 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, + 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 6d88f34422..c76afd0930 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 65067ec784..85dfa963da 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/lib/gastown/types/router.d.ts b/src/lib/gastown/types/router.d.ts index 3f616b1f90..591ea8bef3 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; From 3df78435d2cceb52014f6de1b61d489cdbc6a7b6 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 09:37:56 -0500 Subject: [PATCH 2/5] feat(gastown): build 'Needs Your Attention' section for Merge Queue page (#1365) * feat(gastown): configurable terminal orientation with drag-to-resize (#1299) * feat(gastown): configurable terminal orientation and drag-to-resize (#1237) Terminal bar can be positioned at bottom/top/left/right with drag-to-resize. Position and size persisted to localStorage. Collapsed state only reserves the tab bar strip. Dynamic page padding replaces static pb-[340px]. DrawerStack offsets when terminal is on the right. * fix(gastown): clamp size on orientation switch, add vertical close button Re-clamp persisted size when switching position so horizontal minimum (100px) doesn't persist as a too-small vertical width (min 200px). Show close button on agent tabs in vertical mode via absolute positioning on hover. * fix(gastown): fix terminal resize handle and position picker for left/right orientations - Make resize handle a flex child instead of absolute positioned to avoid z-index conflicts with framer-motion stacking contexts - Add visible hover indicator pill on resize handle for discoverability - Remove width/height from CSS transition so drag resize is immediate - Use fixed positioning with viewport clamping for position picker popup to prevent overflow on left/right orientations * chore: cherry-pick getMergeQueueData types from bead 0 * feat(gastown): build 'Needs Your Attention' section for Merge Queue page Add NeedsAttention component with: - Three attention categories: open PRs, failed reviews, stale PRs - Convoy grouping with header cards showing progress, branch, merge mode - Action buttons: Open PR, Retry Review, Fail Bead, View Bead - DrawerStack integration for bead and convoy drawer opening - Confirmation dialogs for destructive actions - Empty state when no items need attention Rewrite MergesPageClient to use getMergeQueueData tRPC procedure with 5s polling interval. --- .../src/dos/town/review-queue.ts | 75 +- src/app/(app)/gastown/[townId]/layout.tsx | 7 +- .../[townId]/merges/MergesPageClient.tsx | 92 +- .../[townId]/merges/NeedsAttention.tsx | 502 ++++++++++ .../[id]/gastown/[townId]/layout.tsx | 7 +- src/components/gastown/DrawerStack.tsx | 35 +- src/components/gastown/TerminalBar.tsx | 941 +++++++++++++----- src/components/gastown/TerminalBarContext.tsx | 97 +- src/components/gastown/TerminalBarPadding.tsx | 37 + 9 files changed, 1487 insertions(+), 306 deletions(-) create mode 100644 src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx create mode 100644 src/components/gastown/TerminalBarPadding.tsx 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}
+
+ ); +} From e3b5cf01b7b620ac2efd50f83fea310b35328c5a Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 11:16:47 -0500 Subject: [PATCH 3/5] feat(gastown): add convoy grouping and per-rig filtering to Merge Queue page (#1366) * feat(gastown): add convoy grouping and per-rig filtering to Merge Queue page - Add RefineryActivityLog with convoy grouping: entries grouped by convoy with header cards showing title, branch, progress, clickable to open convoy drawer - Add per-rig filter dropdown using listRigs query and shadcn Select, passes rigId to getMergeQueueData for server-side filtering - Include status_changed ActionType with type guard (no unsafe 'as' cast) - Polish layout: page title, rig filter, Needs Attention, Activity Log sections with consistent headers and empty states * fix(gastown): move hooks above early returns and paginate by convoy groups - Move all useMemo hooks above isLoading/empty early returns to prevent React crash when entries transition from 0 to non-zero (hooks must be called in the same order on every render) - Replace flat-entry pagination with group-based pagination: convoy groups (sorted by most recent activity) are kept whole, and standalone entries fill remaining page budget. This ensures recently active convoys appear on page 1 regardless of other convoys' sizes. --- .../[townId]/merges/MergesPageClient.tsx | 72 ++- .../[townId]/merges/RefineryActivityLog.tsx | 531 ++++++++++++++++++ 2 files changed, 599 insertions(+), 4 deletions(-) create mode 100644 src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx diff --git a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx index 9458bc0aff..ff9186841d 100644 --- a/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx +++ b/src/app/(app)/gastown/[townId]/merges/MergesPageClient.tsx @@ -1,15 +1,36 @@ 'use client'; +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useGastownTRPC } from '@/lib/gastown/trpc'; -import { GitMerge, AlertCircle, Loader2 } from 'lucide-react'; +import { GitMerge, AlertCircle, Loader2, Activity, Server } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { NeedsAttention } from './NeedsAttention'; +import { RefineryActivityLog } from './RefineryActivityLog'; + +const ALL_RIGS = '__all__'; export function MergesPageClient({ townId }: { townId: string }) { const trpc = useGastownTRPC(); + const [selectedRigId, setSelectedRigId] = useState(ALL_RIGS); + + const rigIdParam = selectedRigId === ALL_RIGS ? undefined : selectedRigId; + + const rigsQuery = useQuery(trpc.gastown.listRigs.queryOptions({ townId })); + const rigs = rigsQuery.data ?? []; const mergeQueueQuery = useQuery({ - ...trpc.gastown.getMergeQueueData.queryOptions({ townId }), + ...trpc.gastown.getMergeQueueData.queryOptions({ + townId, + rigId: rigIdParam, + limit: 200, + }), refetchInterval: 5_000, }); @@ -33,6 +54,27 @@ export function MergesPageClient({ townId }: { townId: string }) { )}
+ + {/* Rig filter */} +
+ + +
{/* Content */} @@ -60,13 +102,35 @@ export function MergesPageClient({ townId }: { townId: string }) {
- +

Needs Your Attention - +

+ {totalAttention > 0 && ( + + {totalAttention} + + )}
)} + + {/* Refinery Activity Log section */} + {mergeQueueQuery.data && ( +
+
+ +

+ Refinery Activity Log +

+
+ +
+ )}
diff --git a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx new file mode 100644 index 0000000000..8bc905c501 --- /dev/null +++ b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx @@ -0,0 +1,531 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { + GitMerge, + GitPullRequest, + GitBranch, + AlertTriangle, + RotateCcw, + Send, + XCircle, + Activity, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; +import { useDrawerStack } from '@/components/gastown/DrawerStack'; +import type { GastownOutputs } from '@/lib/gastown/trpc'; + +type MergeQueueData = GastownOutputs['gastown']['getMergeQueueData']; +type ActivityLogEntry = MergeQueueData['activityLog'][number]; + +type ActionType = + | 'merged' + | 'failed' + | 'pr_created' + | 'pr_creation_failed' + | 'rework_requested' + | 'review_submitted' + | 'status_changed'; + +const ACTION_CONFIG: Record< + ActionType, + { + icon: typeof GitMerge; + dotColor: string; + lineColor: string; + } +> = { + merged: { + icon: GitMerge, + dotColor: 'bg-emerald-400', + lineColor: 'border-emerald-500/30', + }, + failed: { + icon: XCircle, + dotColor: 'bg-red-400', + lineColor: 'border-red-500/30', + }, + pr_created: { + icon: GitPullRequest, + dotColor: 'bg-sky-400', + lineColor: 'border-sky-500/30', + }, + pr_creation_failed: { + icon: AlertTriangle, + dotColor: 'bg-red-400', + lineColor: 'border-red-500/30', + }, + rework_requested: { + icon: RotateCcw, + dotColor: 'bg-amber-400', + lineColor: 'border-amber-500/30', + }, + review_submitted: { + icon: Send, + dotColor: 'bg-indigo-400', + lineColor: 'border-indigo-500/30', + }, + status_changed: { + icon: Activity, + dotColor: 'bg-white/40', + lineColor: 'border-white/10', + }, +}; + +function isActionType(value: string): value is ActionType { + return value in ACTION_CONFIG; +} + +function resolveActionType(entry: ActivityLogEntry): ActionType { + const eventType = entry.event.event_type; + if (eventType === 'review_completed') { + return entry.event.new_value === 'merged' ? 'merged' : 'failed'; + } + if (isActionType(eventType)) { + return eventType; + } + return 'status_changed'; +} + +function extractPrNumber(prUrl: string | null): string | null { + if (!prUrl) return null; + const match = /\/pull\/(\d+)/.exec(prUrl); + return match ? match[1] : null; +} + +function extractMessage(entry: ActivityLogEntry): string | null { + const meta = entry.event.metadata; + if (typeof meta.message === 'string') return meta.message; + if (typeof meta.feedback === 'string') return meta.feedback; + if (typeof meta.reason === 'string') return meta.reason; + return null; +} + +function buildDescription(entry: ActivityLogEntry): { + prefix: string; + beadTitle: string; + suffix: string; +} { + const action = resolveActionType(entry); + const agentName = entry.agent?.name ?? 'an agent'; + const beadTitle = entry.sourceBead?.title ?? entry.mrBead?.title ?? 'untitled bead'; + const targetBranch = entry.reviewMetadata?.target_branch; + + const branchSuffix = targetBranch + ? targetBranch === 'main' + ? ' into main' + : ` into ${targetBranch}` + : ''; + + const convoySuffix = entry.convoy && branchSuffix ? ` (convoy: ${entry.convoy.title})` : ''; + + switch (action) { + case 'merged': + return { + prefix: `Refinery merged ${agentName}\u2019s `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: `${branchSuffix}${convoySuffix}`, + }; + case 'failed': + return { + prefix: `Refinery review failed for ${agentName}\u2019s `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: '', + }; + case 'pr_created': { + const prUrl = entry.reviewMetadata?.pr_url ?? null; + const prNum = extractPrNumber(prUrl); + const prLabel = prNum ? `PR #${prNum}` : 'a PR'; + return { + prefix: `Refinery created ${prLabel} for ${agentName}\u2019s `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: '', + }; + } + case 'pr_creation_failed': + return { + prefix: `Refinery failed to create PR for ${agentName}\u2019s `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: '', + }; + case 'rework_requested': + return { + prefix: `Refinery requested changes from ${agentName} on `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: '', + }; + case 'review_submitted': + return { + prefix: `${agentName} submitted `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: ' for review', + }; + case 'status_changed': + return { + prefix: `Status changed on `, + beadTitle: `\u201c${beadTitle}\u201d`, + suffix: entry.event.new_value ? ` \u2192 ${entry.event.new_value}` : '', + }; + } +} + +// ── Convoy grouping ────────────────────────────────────────────────── + +type ConvoyInfo = NonNullable; + +type ConvoyActivityGroup = { + convoy: ConvoyInfo; + entries: ActivityLogEntry[]; + latestTimestamp: string; +}; + +function groupActivityByConvoy(entries: ActivityLogEntry[]): { + convoyGroups: ConvoyActivityGroup[]; + standalone: ActivityLogEntry[]; +} { + const convoyMap = new Map(); + const standalone: ActivityLogEntry[] = []; + + for (const entry of entries) { + if (entry.convoy) { + const existing = convoyMap.get(entry.convoy.convoy_id); + if (existing) { + existing.entries.push(entry); + if (entry.event.created_at > existing.latestTimestamp) { + existing.latestTimestamp = entry.event.created_at; + } + } else { + convoyMap.set(entry.convoy.convoy_id, { + convoy: entry.convoy, + entries: [entry], + latestTimestamp: entry.event.created_at, + }); + } + } else { + standalone.push(entry); + } + } + + // Sort convoy groups by most recent activity + const convoyGroups = [...convoyMap.values()].sort((a, b) => + b.latestTimestamp.localeCompare(a.latestTimestamp) + ); + + return { convoyGroups, standalone }; +} + +// ── Main component ─────────────────────────────────────────────────── + +const PAGE_SIZE = 20; + +export function RefineryActivityLog({ + activityLog, + isLoading, + townId, +}: { + activityLog: ActivityLogEntry[] | undefined; + isLoading: boolean; + townId: string; +}) { + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const entries = activityLog ?? []; + + // Groups are sorted by most recent activity (latestTimestamp descending) + const { convoyGroups, standalone } = useMemo(() => groupActivityByConvoy(entries), [entries]); + + // Paginate by convoy groups (keeps whole convoys together on a page). + // Each convoy group counts as its entry count toward the page budget; + // standalone entries fill the remaining budget after convoy groups. + const { visibleConvoyGroups, visibleStandalone, totalEntryCount, hasMore } = useMemo(() => { + const visibleConvoys: ConvoyActivityGroup[] = []; + let budget = visibleCount; + + for (const group of convoyGroups) { + if (budget <= 0) break; + visibleConvoys.push(group); + budget -= group.entries.length; + } + + // Fill remaining budget with standalone entries (already sorted by recency from the query) + const standaloneSlice = budget > 0 ? standalone.slice(0, budget) : []; + + const total = convoyGroups.reduce((sum, g) => sum + g.entries.length, 0) + standalone.length; + + return { + visibleConvoyGroups: visibleConvoys, + visibleStandalone: standaloneSlice, + totalEntryCount: total, + hasMore: visibleCount < total, + }; + }, [convoyGroups, standalone, visibleCount]); + + // All hooks are above — early returns below + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + +
+
+
+
+
+
+
+
+
+ + ))} +
+ ); + } + + if (entries.length === 0) { + return ( + + +

No refinery activity yet

+

+ Merge reviews, PR creations, and rework requests will appear here. +

+
+ ); + } + + return ( +
+
+ {/* Convoy grouped entries — sorted by most recent activity */} + + {visibleConvoyGroups.map(group => ( + + ))} + + + {/* Standalone entries */} + {visibleStandalone.length > 0 && ( +
+ + {visibleStandalone.map((entry, i, arr) => ( + + ))} + +
+ )} +
+ + {hasMore && ( + + )} +
+ ); +} + +// ── Convoy activity group card ─────────────────────────────────────── + +function ConvoyActivityGroupCard({ + convoy, + entries, + townId, +}: { + convoy: ConvoyInfo; + entries: ActivityLogEntry[]; + townId: string; +}) { + const { open: openDrawer } = useDrawerStack(); + const progress = + convoy.total_beads > 0 ? `${convoy.closed_beads}/${convoy.total_beads} beads reviewed` : ''; + + return ( + + {/* Convoy header */} + + + {/* Progress bar */} + {convoy.total_beads > 0 && ( +
+ +
+ )} + + {/* Timeline entries within convoy */} +
+ + {entries.map((entry, i) => ( + + ))} + +
+
+ ); +} + +// ── Timeline entry ─────────────────────────────────────────────────── + +function TimelineEntry({ + entry, + isLast, + delay, +}: { + entry: ActivityLogEntry; + isLast: boolean; + delay: number; +}) { + const { open } = useDrawerStack(); + const action = resolveActionType(entry); + const config = ACTION_CONFIG[action]; + const Icon = config.icon; + const description = buildDescription(entry); + const message = extractMessage(entry); + const commitSha = entry.reviewMetadata?.merge_commit ?? null; + const prUrl = entry.reviewMetadata?.pr_url ?? null; + const prNumber = extractPrNumber(prUrl); + const rigName = entry.rigName; + const rigId = entry.mrBead?.rig_id; + + function handleBeadClick() { + const beadId = entry.sourceBead?.bead_id ?? entry.mrBead?.bead_id; + if (beadId && rigId) { + open({ type: 'bead', beadId, rigId }); + } + } + + return ( + + {/* Timeline indicator */} +
+
+ {!isLast && ( +
+ )} +
+ + {/* Content */} +
+ {/* Rig name + timestamp header */} +
+ {rigName && {rigName}} + {rigName && ·} + {formatDistanceToNow(new Date(entry.event.created_at), { addSuffix: true })} + +
+ + {/* Main description */} +

+ {description.prefix} + + {description.suffix} +

+ + {/* Reason/message line */} + {message && ( +

+ {action === 'rework_requested' ? 'Reason: ' : ''} + {message} +

+ )} + + {/* Metadata line */} +
+ {commitSha && Commit {commitSha.slice(0, 7)}} + {prUrl && prNumber && ( + + PR #{prNumber} + + )} + {prUrl && !prNumber && ( + + View PR + + )} +
+
+ + ); +} From 41c53fba23d9b59613d7c8d4a48c4c2dde5f5f04 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sun, 22 Mar 2026 16:15:09 +0000 Subject: [PATCH 4/5] fix(gastown): interleave convoy groups and standalone entries by recency in pagination The previous pagination showed all convoy groups before any standalone entries regardless of timestamps. A standalone entry more recent than all convoy groups would still appear below them on page 1. Merge convoy groups and standalone entries into a unified DisplayItem list sorted by most-recent timestamp, then paginate over that list. Convoy groups use their latestTimestamp as sort key; standalone entries use their event.created_at. This ensures the most recently active items appear first on page 1 regardless of whether they are convoy-grouped or standalone. Hooks were already correctly placed before early returns (no conditional hooks issue in the current code). --- .../[townId]/merges/RefineryActivityLog.tsx | 91 +++++++++++-------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx index 8bc905c501..c13474776c 100644 --- a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx +++ b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx @@ -234,27 +234,49 @@ export function RefineryActivityLog({ // 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[] = []; + // Merge convoy groups and standalone entries into a single list sorted by + // most-recent timestamp, then paginate over that unified list. This ensures + // the most recently active items (whether convoy or standalone) appear first, + // rather than showing all convoy groups before any standalone entries. + type DisplayItem = + | { kind: 'convoy'; group: ConvoyActivityGroup; sortKey: string } + | { kind: 'standalone'; entry: ActivityLogEntry; sortKey: string }; + + const { visibleItems, totalEntryCount, hasMore } = useMemo(() => { + const items: DisplayItem[] = [ + ...convoyGroups.map( + (group): DisplayItem => ({ + kind: 'convoy', + group, + sortKey: group.latestTimestamp, + }) + ), + ...standalone.map( + (entry): DisplayItem => ({ + kind: 'standalone', + entry, + sortKey: entry.event.created_at, + }) + ), + ]; + + // Sort by most recent first + items.sort((a, b) => b.sortKey.localeCompare(a.sortKey)); + + // Paginate: each convoy group costs its entry count, each standalone costs 1 + const visible: DisplayItem[] = []; let budget = visibleCount; - for (const group of convoyGroups) { + for (const item of items) { if (budget <= 0) break; - visibleConvoys.push(group); - budget -= group.entries.length; + visible.push(item); + budget -= item.kind === 'convoy' ? item.group.entries.length : 1; } - // 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, + visibleItems: visible, totalEntryCount: total, hasMore: visibleCount < total, }; @@ -306,33 +328,26 @@ export function RefineryActivityLog({ return (
- {/* Convoy grouped entries — sorted by most recent activity */} + {/* Convoy groups and standalone entries interleaved by recency */} - {visibleConvoyGroups.map(group => ( - - ))} + {visibleItems.map((item, idx) => + item.kind === 'convoy' ? ( + + ) : ( + + ) + )} - - {/* Standalone entries */} - {visibleStandalone.length > 0 && ( -
- - {visibleStandalone.map((entry, i, arr) => ( - - ))} - -
- )}
{hasMore && ( From e3bc4bb6d8ca7d5d0a9b40f2d849a096acfe382d Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 23 Mar 2026 10:26:25 -0500 Subject: [PATCH 5/5] fix(gastown): gate fail button behind admin check and fix pagination remaining count Hide the admin-only 'Fail Bead' button for non-admin users by checking isAdmin from the session. Fix the activity log pagination to compute hasMore and remaining count based on actual visible entries rather than the budget, preventing incorrect 'Show more' state when a convoy group exceeds the page budget. --- .../[townId]/merges/NeedsAttention.tsx | 79 ++++++++++++++----- .../[townId]/merges/RefineryActivityLog.tsx | 22 ++++-- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx b/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx index bb47bb5a20..a7fc670317 100644 --- a/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx +++ b/src/app/(app)/gastown/[townId]/merges/NeedsAttention.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, Fragment } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; import { useGastownTRPC } from '@/lib/gastown/trpc'; import type { GastownOutputs } from '@/lib/gastown/trpc'; import { useDrawerStack } from '@/components/gastown/DrawerStack'; @@ -39,7 +40,11 @@ type ConvoyGroup = { items: MergeQueueItem[]; }; -type ConfirmAction = { beadId: string; title: string; action: 'fail' | 'retry' }; +type ConfirmAction = { + beadId: string; + title: string; + action: 'fail' | 'retry'; +}; // ── Status badges ──────────────────────────────────────────────────── @@ -106,6 +111,8 @@ export function NeedsAttention({ data: MergeQueueData['needsAttention']; townId: string; }) { + const session = useSession(); + const isAdmin = session?.data?.isAdmin ?? false; const totalCount = data.openPRs.length + data.failedReviews.length + data.stalePRs.length; // Tag each item with its category for rendering @@ -153,7 +160,12 @@ export function NeedsAttention({ exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.25 }} > - + ))} @@ -172,6 +184,7 @@ export function NeedsAttention({ item={item} category={categoryByBeadId.get(item.mrBead.bead_id) ?? 'openPR'} townId={townId} + isAdmin={isAdmin} /> ))} @@ -186,10 +199,12 @@ function ConvoyGroupCard({ group, categoryByBeadId, townId, + isAdmin, }: { group: ConvoyGroup; categoryByBeadId: Map; townId: string; + isAdmin: boolean; }) { const { open: openDrawer } = useDrawerStack(); const { convoy, items } = group; @@ -231,7 +246,9 @@ function ConvoyGroupCard({
@@ -246,6 +263,7 @@ function ConvoyGroupCard({ item={item} category={categoryByBeadId.get(item.mrBead.bead_id) ?? 'openPR'} townId={townId} + isAdmin={isAdmin} /> ))} @@ -260,14 +278,16 @@ function AttentionItemCard({ item, category, townId, + isAdmin, }: { item: MergeQueueItem; category: Category; townId: string; + isAdmin: boolean; }) { return (
- +
); } @@ -278,10 +298,12 @@ function AttentionItemRow({ item, category, townId, + isAdmin, }: { item: MergeQueueItem; category: Category; townId: string; + isAdmin: boolean; }) { const { open: openDrawer } = useDrawerStack(); const trpc = useGastownTRPC(); @@ -367,7 +389,11 @@ function AttentionItemRow({ - + {isAdmin && ( + + )}
diff --git a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx index c13474776c..15a3951c58 100644 --- a/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx +++ b/src/app/(app)/gastown/[townId]/merges/RefineryActivityLog.tsx @@ -242,7 +242,7 @@ export function RefineryActivityLog({ | { kind: 'convoy'; group: ConvoyActivityGroup; sortKey: string } | { kind: 'standalone'; entry: ActivityLogEntry; sortKey: string }; - const { visibleItems, totalEntryCount, hasMore } = useMemo(() => { + const { visibleItems, totalEntryCount, visibleEntryCount, hasMore } = useMemo(() => { const items: DisplayItem[] = [ ...convoyGroups.map( (group): DisplayItem => ({ @@ -266,11 +266,14 @@ export function RefineryActivityLog({ // Paginate: each convoy group costs its entry count, each standalone costs 1 const visible: DisplayItem[] = []; let budget = visibleCount; + let visibleEntries = 0; for (const item of items) { if (budget <= 0) break; + const cost = item.kind === 'convoy' ? item.group.entries.length : 1; visible.push(item); - budget -= item.kind === 'convoy' ? item.group.entries.length : 1; + budget -= cost; + visibleEntries += cost; } const total = convoyGroups.reduce((sum, g) => sum + g.entries.length, 0) + standalone.length; @@ -278,7 +281,8 @@ export function RefineryActivityLog({ return { visibleItems: visible, totalEntryCount: total, - hasMore: visibleCount < total, + visibleEntryCount: visibleEntries, + hasMore: visibleEntries < total, }; }, [convoyGroups, standalone, visibleCount]); @@ -357,7 +361,7 @@ export function RefineryActivityLog({ > Show more - {totalEntryCount - visibleCount} remaining + {totalEntryCount - visibleEntryCount} remaining )} @@ -416,7 +420,9 @@ function ConvoyActivityGroupCard({
@@ -492,7 +498,11 @@ function TimelineEntry({
{rigName && {rigName}} {rigName && ·} - {formatDistanceToNow(new Date(entry.event.created_at), { addSuffix: true })} + + {formatDistanceToNow(new Date(entry.event.created_at), { + addSuffix: true, + })} +