|
| 1 | +import type { HotTicket, HotBucket } from '@gemstack/the-framework' |
| 2 | +import { Flame } from 'lucide-react' |
| 3 | +import { onHotTickets } from '../server/reads.telefunc.js' |
| 4 | +import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js' |
| 5 | +import { usePolled } from '../lib/use-async.js' |
| 6 | +import { cn } from '../lib/utils.js' |
| 7 | + |
| 8 | +// The Overview's "hot tickets" card (#1112): a cross-project glance at what the agent is working on |
| 9 | +// (planned/spiked), what is likely next (high priority), and the queued rest. A projection of every |
| 10 | +// project's `tickets/` over the `onHotTickets` read, polled so it stays live. Selecting a ticket |
| 11 | +// jumps into its project. Advertised on the landing page, so it earns a place on the landing view. |
| 12 | + |
| 13 | +const EMPTY: HotTicket[] = [] |
| 14 | + |
| 15 | +// The three lanes, in the order Rom listed them (#1112): worked-on, next, queued. Each carries the |
| 16 | +// dot colour that matches the rest of the status vocabulary (primary = active, warning = soon). |
| 17 | +// Stacked as full-width sections rather than columns, so an uneven split (the common case, where |
| 18 | +// most tickets sit queued) still reads as designed instead of two empty columns. |
| 19 | +const LANES: { key: HotBucket; label: string; dot: string }[] = [ |
| 20 | + { key: 'in-progress', label: 'In progress', dot: 'bg-primary' }, |
| 21 | + { key: 'next', label: 'Up next', dot: 'bg-warning' }, |
| 22 | + { key: 'queued', label: 'Queued', dot: 'bg-muted-foreground' }, |
| 23 | +] |
| 24 | + |
| 25 | +// A lane is capped so the card stays a glance; the rest is summarised as "+N more". |
| 26 | +const PER_LANE = 5 |
| 27 | + |
| 28 | +export function HotTickets({ onSelectProject }: { onSelectProject: (id: string) => void }) { |
| 29 | + const { value: tickets } = usePolled<HotTicket[]>(onHotTickets, EMPTY, 10_000, []) |
| 30 | + |
| 31 | + return ( |
| 32 | + <Card> |
| 33 | + <CardHeader> |
| 34 | + <CardTitle className="flex items-center gap-2"> |
| 35 | + <Flame className="h-4 w-4 text-muted-foreground" /> |
| 36 | + Hot tickets |
| 37 | + </CardTitle> |
| 38 | + </CardHeader> |
| 39 | + <CardContent> |
| 40 | + {tickets.length === 0 ? ( |
| 41 | + <p className="py-2 text-sm text-muted-foreground">No tickets yet.</p> |
| 42 | + ) : ( |
| 43 | + <div className="divide-y divide-border"> |
| 44 | + {LANES.map(lane => ( |
| 45 | + <Lane key={lane.key} lane={lane} tickets={tickets.filter(t => t.bucket === lane.key)} onSelectProject={onSelectProject} /> |
| 46 | + ))} |
| 47 | + </div> |
| 48 | + )} |
| 49 | + </CardContent> |
| 50 | + </Card> |
| 51 | + ) |
| 52 | +} |
| 53 | + |
| 54 | +function Lane({ |
| 55 | + lane, |
| 56 | + tickets, |
| 57 | + onSelectProject, |
| 58 | +}: { |
| 59 | + lane: { key: HotBucket; label: string; dot: string } |
| 60 | + tickets: HotTicket[] |
| 61 | + onSelectProject: (id: string) => void |
| 62 | +}) { |
| 63 | + const shown = tickets.slice(0, PER_LANE) |
| 64 | + const more = tickets.length - shown.length |
| 65 | + const empty = tickets.length === 0 |
| 66 | + return ( |
| 67 | + <div className="py-3 first:pt-0 last:pb-0"> |
| 68 | + <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide"> |
| 69 | + {/* An empty lane dims to a single header line rather than a paragraph, so the populated |
| 70 | + lane carries the card and the zeros still say "nothing here" at a glance. */} |
| 71 | + <span aria-hidden className={cn('h-2 w-2 shrink-0 rounded-full', lane.dot, empty && 'opacity-40')} /> |
| 72 | + <span className={empty ? 'text-muted-foreground' : 'text-foreground/80'}>{lane.label}</span> |
| 73 | + <span className="tabular-nums text-muted-foreground/70">{tickets.length}</span> |
| 74 | + </div> |
| 75 | + {!empty && ( |
| 76 | + <ul className="mt-1.5"> |
| 77 | + {shown.map(t => ( |
| 78 | + <li key={`${t.projectId}:${t.ticket.file}`}> |
| 79 | + <button |
| 80 | + type="button" |
| 81 | + onClick={() => onSelectProject(t.projectId)} |
| 82 | + title={t.ticket.summary || t.ticket.title} |
| 83 | + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none" |
| 84 | + > |
| 85 | + <span className="min-w-0 flex-1 truncate text-sm font-medium">{t.ticket.title}</span> |
| 86 | + <TicketTag ticket={t} /> |
| 87 | + <span className="shrink-0 text-xs text-muted-foreground">{t.projectName}</span> |
| 88 | + </button> |
| 89 | + </li> |
| 90 | + ))} |
| 91 | + {more > 0 && ( |
| 92 | + <li className="px-2 pt-0.5 text-xs text-muted-foreground"> |
| 93 | + +{more} more |
| 94 | + </li> |
| 95 | + )} |
| 96 | + </ul> |
| 97 | + )} |
| 98 | + </div> |
| 99 | + ) |
| 100 | +} |
| 101 | + |
| 102 | +// The one fact that earns the lane: the plan/spike that made it in-progress, or the priority that |
| 103 | +// made it next. Queued rows carry nothing extra — the lane already says it. |
| 104 | +function TicketTag({ ticket: t }: { ticket: HotTicket }) { |
| 105 | + const tag = t.bucket === 'in-progress' ? (t.ticket.planned ? 'planned' : t.ticket.spiked ? 'spiked' : null) : t.bucket === 'next' ? t.ticket.priority ?? null : null |
| 106 | + if (!tag) return null |
| 107 | + return ( |
| 108 | + <span className="shrink-0 rounded border border-border px-1 text-[10px] uppercase tracking-wide text-muted-foreground"> |
| 109 | + {tag} |
| 110 | + </span> |
| 111 | + ) |
| 112 | +} |
0 commit comments