Skip to content

Commit ef0d941

Browse files
authored
feat(dashboard): add a Hot tickets overview (#1112) (#1114)
* feat(dashboard): pool hot tickets across projects Add buildHotTickets + an onHotTickets telefunc that pool every project's tickets/ and bucket each into a lane: in-progress (planned or spiked), next (high priority, not started), or queued (the rest). There is no run to ticket link, so a ticket being implemented right now is only visible through the plan/spike it left. Exported through the package surface and the client shim; the telefunc auto-registers. * feat(dashboard): add a Hot tickets card to the Overview A cross-project glance at what the agent is working on (planned/spiked), what is likely next (high priority), and the queued rest. Full-width stacked lanes so an uneven split still reads as designed: an empty lane collapses to a dim header line, a populated one is a list of titles with their project, capped with "+N more". Selecting a ticket jumps into its project (#1112).
1 parent 4dab165 commit ef0d941

11 files changed

Lines changed: 283 additions & 7 deletions

File tree

.changeset/hot-tickets-overview.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@gemstack/the-framework": minor
3+
---
4+
5+
Add a "Hot tickets" overview to the dashboard.
6+
7+
The Overview now has a cross-project glance at the tickets that matter right now, in three lanes: In progress (tickets the agent has planned or spiked), Up next (high priority, not started yet), and Queued (the rest of the open backlog). Each row names its project and jumps into it when selected.
8+
9+
It pools every project's `tickets/`, so it is a projection of the same files the agent plans from, polled so it stays live. Empty lanes collapse to a single header line, so an import-heavy repo where everything sits queued still reads as designed.

packages/framework-dashboard/components/DashboardPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
1010
import { usePolled } from '../lib/use-async.js'
1111
import { usePreferences } from '../lib/preferences.js'
1212
import { OnboardingChecklist } from './OnboardingChecklist.js'
13+
import { HotTickets } from './HotTickets.js'
1314
import { cn } from '../lib/utils.js'
1415
import { formatDateTime, formatRelative } from '../lib/format-date.js'
1516
import { ScrollArea } from './ui/scroll-area.js'
@@ -43,6 +44,8 @@ export function DashboardPage({
4344

4445
<NeedsYou items={interventions} onSelectProject={onSelectProject} />
4546

47+
<HotTickets onSelectProject={onSelectProject} />
48+
4649
{data === null ? (
4750
<p className="text-sm text-muted-foreground">Loading…</p>
4851
) : (
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterEach, describe, expect, test, vi } from 'vitest'
2+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
3+
import type { HotTicket, HotBucket } from '@gemstack/the-framework'
4+
5+
// HotTickets reads onHotTickets over the telefunc shim; stub it so the import graph stays out of
6+
// telefunc and the poll returns fixtures.
7+
const onHotTickets = vi.hoisted(() => vi.fn())
8+
vi.mock('../server/reads.telefunc.js', () => ({ onHotTickets }))
9+
10+
const { HotTickets } = await import('./HotTickets.js')
11+
12+
afterEach(cleanup)
13+
14+
const ht = (file: string, projectName: string, bucket: HotBucket, over: Record<string, unknown> = {}): HotTicket => ({
15+
projectId: projectName,
16+
projectName,
17+
bucket,
18+
ticket: { file, title: file.replace('.md', ''), summary: '', spiked: false, planned: false, ...over },
19+
})
20+
21+
describe('HotTickets (#1112)', () => {
22+
test('with no tickets it shows a hint', async () => {
23+
onHotTickets.mockResolvedValue([])
24+
render(<HotTickets onSelectProject={() => {}} />)
25+
await waitFor(() => expect(screen.getByText('No tickets yet.')).toBeTruthy())
26+
})
27+
28+
test('groups tickets into the three lanes and selecting one jumps into its project', async () => {
29+
onHotTickets.mockResolvedValue([
30+
ht('a.md', 'alpha', 'in-progress', { planned: true }),
31+
ht('b.md', 'beta', 'next', { priority: 'high' }),
32+
ht('c.md', 'alpha', 'queued'),
33+
])
34+
let picked: string | null = null
35+
render(<HotTickets onSelectProject={id => (picked = id)} />)
36+
await waitFor(() => expect(screen.getByText('a')).toBeTruthy())
37+
expect(screen.getByText('In progress')).toBeTruthy()
38+
expect(screen.getByText('Up next')).toBeTruthy()
39+
expect(screen.getByText('Queued')).toBeTruthy()
40+
fireEvent.click(screen.getByText('b'))
41+
expect(picked).toBe('beta')
42+
})
43+
})
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
}

packages/framework-dashboard/server/reads.telefunc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
// Imported then exported, not re-exported (#1014): telefunc's dev transform appends
66
// `__decorateTelefunction(<name>, ...)` per export, which needs a local binding. An
77
// `export ... from` creates none, so `pnpm dev` died with `<name> is not defined`.
8-
import { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onRecentRuns, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser } from '@gemstack/the-framework/dashboard-rpc'
8+
import { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onRecentRuns, onHotTickets, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser } from '@gemstack/the-framework/dashboard-rpc'
99

10-
export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onRecentRuns, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser }
10+
export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onRecentRuns, onHotTickets, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser }

packages/the-framework/src/dashboard-rpc/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// implementations live here in @gemstack/the-framework so `sendStart` (added with the serve
33
// wiring) can reach the daemon's `startRun`; the framework-dashboard client imports
44
// these through thin re-export shims so the baked RPC keys stay `/server/*.telefunc.ts`.
5-
export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onRecentRuns, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser } from './reads.telefunc.js'
5+
export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onRecentRuns, onHotTickets, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser } from './reads.telefunc.js'
66
export { sendStop, sendChoice, sendMessage, sendSetHandoff, sendStart, sendPreview, onServeTargets, sendStopPreview, onPreviewStatus, sendOpenInApp, sendRemoveWorktree, sendDeleteSession, sendPushBranch, sendOpenPullRequest, sendQueueTicket, type QueueTicketResult } from './control.telefunc.js'
77
export { onEvents } from './events.telefunc.js'
88
export { onProjects, sendAddProject, onOnboarding } from './projects.telefunc.js'

packages/the-framework/src/dashboard-rpc/reads.telefunc.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { readLogs, type LogEntry } from '../logs.js'
55
import { readDocs, type WorkspaceDoc } from '../dashboard/docs.js'
66
import { readTickets, type WorkspaceTicket } from '../dashboard/tickets.js'
77
import { collectQueue, type ProjectQueue } from '../dashboard/queue.js'
8-
import { buildOverview, buildRecentRuns, type Overview, type RecentRun } from '../dashboard/overview.js'
8+
import { buildOverview, buildRecentRuns, buildHotTickets, type Overview, type RecentRun, type HotTicket } from '../dashboard/overview.js'
99
import { buildInterventions, type Intervention } from '../dashboard/interventions.js'
1010
import { buildActivity, type Activity } from '../dashboard/activity.js'
1111
import { buildDashboard, type DashboardData } from '../dashboard/dashboard.js'
@@ -182,6 +182,11 @@ export async function onRecentRuns(): Promise<RecentRun[]> {
182182
return withProjects(projects => buildRecentRuns(projects))
183183
}
184184

185+
/** Hot tickets across every project (#1112): being worked on, likely next, and queued. */
186+
export async function onHotTickets(): Promise<HotTicket[]> {
187+
return withProjects(projects => buildHotTickets(projects))
188+
}
189+
185190
/** The cross-project interventions queue (#632, Queue #624): open PRs that need review, newest first. */
186191
export async function onInterventions(): Promise<Intervention[]> {
187192
return withProjects(buildInterventions)

packages/the-framework/src/dashboard/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export { serveClientBundle } from './static.js'
1515
export { readDocs, DOC_CATEGORIES, type WorkspaceDoc } from './docs.js'
1616
export { readTickets, type WorkspaceTicket } from './tickets.js'
1717
export { collectQueue, parseTodoItems, type ProjectQueue, type QueueItem } from './queue.js'
18-
export { buildOverview, buildRecentRuns, type Overview, type ActiveRun, type RecentProject, type RecentRun, type OverviewDeps } from './overview.js'
18+
export { buildOverview, buildRecentRuns, buildHotTickets, ticketBucket, type Overview, type ActiveRun, type RecentProject, type RecentRun, type HotTicket, type HotBucket, type OverviewDeps } from './overview.js'
1919
export { buildDashboard, type DashboardData, type ProjectStat, type ActivityDay, type DashboardDeps } from './dashboard.js'
2020
export { readGitStatus, type GitStatus } from './git-status.js'
2121
export { ghPrView, ghPrList, ghJson, nodeGhRunner, type LinkedPr, type OpenPr, type PrLookup, type BranchPrLookup, type PrLister, type GhRunner } from './gh.js'

packages/the-framework/src/dashboard/overview.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { strict as assert } from 'node:assert'
22
import { test } from 'node:test'
3-
import { buildOverview, buildRecentRuns } from './overview.js'
3+
import { buildOverview, buildRecentRuns, buildHotTickets, ticketBucket } from './overview.js'
44
import type { ProjectSummary } from './projects.js'
55
import type { ProjectQueue } from './queue.js'
6+
import type { WorkspaceTicket } from './tickets.js'
67
import type { RunMeta } from '../store/index.js'
78

89
const project = (id: string, path: string, lastActivityAt?: string): ProjectSummary => ({
@@ -91,3 +92,51 @@ test('buildRecentRuns tolerates a project whose runs cannot be read', async () =
9192
})
9293
assert.deepEqual(recent.map(r => r.run.id), ['x'])
9394
})
95+
96+
const ticket = (file: string, over: Partial<WorkspaceTicket> = {}): WorkspaceTicket => ({
97+
file,
98+
title: file,
99+
summary: '',
100+
spiked: false,
101+
planned: false,
102+
...over,
103+
})
104+
105+
test('ticketBucket: planned/spiked is in-progress, high priority is next, else queued', () => {
106+
assert.equal(ticketBucket(ticket('a', { planned: true })), 'in-progress')
107+
assert.equal(ticketBucket(ticket('b', { spiked: true })), 'in-progress')
108+
assert.equal(ticketBucket(ticket('c', { priority: 'high' })), 'next')
109+
assert.equal(ticketBucket(ticket('d', { priority: 'p1' })), 'next')
110+
assert.equal(ticketBucket(ticket('e')), 'queued')
111+
assert.equal(ticketBucket(ticket('f', { priority: 'low' })), 'queued')
112+
// A planned high-prio ticket is in-progress, not next: work already started outranks the flag.
113+
assert.equal(ticketBucket(ticket('g', { planned: true, priority: 'high' })), 'in-progress')
114+
})
115+
116+
test('buildHotTickets pools every project, buckets each, and orders lane-first', async () => {
117+
const tickets: Record<string, WorkspaceTicket[]> = {
118+
'/a': [ticket('a1.md', { planned: true }), ticket('a2.md', { priority: 'high' })],
119+
'/b': [ticket('b1.md')],
120+
}
121+
const hot = await buildHotTickets([project('alpha', '/a'), project('beta', '/b')], {
122+
tickets: async cwd => tickets[cwd] ?? [],
123+
})
124+
assert.deepEqual(
125+
hot.map(h => ({ p: h.projectName, f: h.ticket.file, b: h.bucket })),
126+
[
127+
{ p: 'alpha', f: 'a1.md', b: 'in-progress' },
128+
{ p: 'alpha', f: 'a2.md', b: 'next' },
129+
{ p: 'beta', f: 'b1.md', b: 'queued' },
130+
],
131+
)
132+
})
133+
134+
test('buildHotTickets tolerates a project whose tickets cannot be read', async () => {
135+
const hot = await buildHotTickets([project('ok', '/ok'), project('bad', '/bad')], {
136+
tickets: async cwd => {
137+
if (cwd === '/bad') throw new Error('unreadable')
138+
return [ticket('x.md', { priority: 'high' })]
139+
},
140+
})
141+
assert.deepEqual(hot.map(h => h.ticket.file), ['x.md'])
142+
})

packages/the-framework/src/dashboard/overview.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readAllRuns, readLiveMetas, type LiveRun, type RunMeta, type RunStatus } from '../store/index.js'
22
import type { ProjectSummary } from './projects.js'
33
import { collectQueue, type ProjectQueue } from './queue.js'
4+
import { readTickets, type WorkspaceTicket } from './tickets.js'
45

56
// The first-sidebar Overview (#437, part of #314): a cross-project glance at what the agent
67
// is working on right now, the size of the backlog, and the recently active projects. It
@@ -81,6 +82,60 @@ export async function buildRecentRuns(projects: ProjectSummary[], deps: RecentRu
8182
return all.slice(0, RECENT_RUNS_LIMIT)
8283
}
8384

85+
/** Which lane of the "hot tickets" overview (#1112) a ticket sits in. */
86+
export type HotBucket = 'in-progress' | 'next' | 'queued'
87+
88+
/** One ticket surfaced on the Overview's hot-tickets card, tagged with its project and lane. */
89+
export interface HotTicket {
90+
projectId: string
91+
projectName: string
92+
bucket: HotBucket
93+
ticket: WorkspaceTicket
94+
}
95+
96+
/** Priority values that read as "do this soon" — the "likely next" lane (#1112). */
97+
const HIGH_PRIORITY = new Set(['high', 'urgent', 'critical', 'p0', 'p1', '0', '1'])
98+
99+
/**
100+
* A ticket's lane (#1112):
101+
* - in-progress: the agent has planned or spiked it, i.e. work is under way. (There is no run↔ticket
102+
* link, so a ticket being *implemented* right now is only visible through the plan/spike it left.)
103+
* - next: no plan/spike yet, but flagged high priority — likely the next thing picked up.
104+
* - queued: everything else open, the backlog waiting its turn.
105+
*/
106+
export function ticketBucket(ticket: WorkspaceTicket): HotBucket {
107+
if (ticket.planned || ticket.spiked) return 'in-progress'
108+
if (ticket.priority && HIGH_PRIORITY.has(ticket.priority)) return 'next'
109+
return 'queued'
110+
}
111+
112+
/** How many hot tickets the Overview pools before the card trims per lane. */
113+
const HOT_TICKETS_LIMIT = 60
114+
115+
/** Injectable reader so {@link buildHotTickets} is unit-testable off disk. */
116+
export interface HotTicketsDeps {
117+
tickets?: (cwd: string) => Promise<WorkspaceTicket[]>
118+
}
119+
120+
/**
121+
* Every project's tickets pooled and bucketed for the Overview's "hot tickets" card (#1112): what
122+
* is being worked on (planned/spiked), what is likely next (high priority), and the queued rest.
123+
* Ordered lane-first (in-progress, then next, then queued), file order within a lane. Forgiving —
124+
* a project whose tickets cannot be read simply contributes nothing.
125+
*/
126+
export async function buildHotTickets(projects: ProjectSummary[], deps: HotTicketsDeps = {}): Promise<HotTicket[]> {
127+
const readT = deps.tickets ?? readTickets
128+
const all: HotTicket[] = []
129+
for (const project of projects) {
130+
for (const ticket of await readT(project.path).catch(() => [])) {
131+
all.push({ projectId: project.id, projectName: project.name, bucket: ticketBucket(ticket), ticket })
132+
}
133+
}
134+
const lane: Record<HotBucket, number> = { 'in-progress': 0, next: 1, queued: 2 }
135+
all.sort((a, b) => lane[a.bucket] - lane[b.bucket])
136+
return all.slice(0, HOT_TICKETS_LIMIT)
137+
}
138+
84139
/** Injectable readers so {@link buildOverview} is unit-testable off disk. */
85140
export interface OverviewDeps {
86141
liveRuns?: (cwd: string) => Promise<LiveRun[]>

0 commit comments

Comments
 (0)