Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/hot-tickets-overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@gemstack/the-framework": minor
---

Add a "Hot tickets" overview to the dashboard.

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.

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.
3 changes: 3 additions & 0 deletions packages/framework-dashboard/components/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
import { usePolled } from '../lib/use-async.js'
import { usePreferences } from '../lib/preferences.js'
import { OnboardingChecklist } from './OnboardingChecklist.js'
import { HotTickets } from './HotTickets.js'
import { cn } from '../lib/utils.js'
import { formatDateTime, formatRelative } from '../lib/format-date.js'
import { ScrollArea } from './ui/scroll-area.js'
Expand Down Expand Up @@ -43,6 +44,8 @@ export function DashboardPage({

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

<HotTickets onSelectProject={onSelectProject} />

{data === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : (
Expand Down
43 changes: 43 additions & 0 deletions packages/framework-dashboard/components/HotTickets.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { HotTicket, HotBucket } from '@gemstack/the-framework'

// HotTickets reads onHotTickets over the telefunc shim; stub it so the import graph stays out of
// telefunc and the poll returns fixtures.
const onHotTickets = vi.hoisted(() => vi.fn())
vi.mock('../server/reads.telefunc.js', () => ({ onHotTickets }))

const { HotTickets } = await import('./HotTickets.js')

afterEach(cleanup)

const ht = (file: string, projectName: string, bucket: HotBucket, over: Record<string, unknown> = {}): HotTicket => ({
projectId: projectName,
projectName,
bucket,
ticket: { file, title: file.replace('.md', ''), summary: '', spiked: false, planned: false, ...over },
})

describe('HotTickets (#1112)', () => {
test('with no tickets it shows a hint', async () => {
onHotTickets.mockResolvedValue([])
render(<HotTickets onSelectProject={() => {}} />)
await waitFor(() => expect(screen.getByText('No tickets yet.')).toBeTruthy())
})

test('groups tickets into the three lanes and selecting one jumps into its project', async () => {
onHotTickets.mockResolvedValue([
ht('a.md', 'alpha', 'in-progress', { planned: true }),
ht('b.md', 'beta', 'next', { priority: 'high' }),
ht('c.md', 'alpha', 'queued'),
])
let picked: string | null = null
render(<HotTickets onSelectProject={id => (picked = id)} />)
await waitFor(() => expect(screen.getByText('a')).toBeTruthy())
expect(screen.getByText('In progress')).toBeTruthy()
expect(screen.getByText('Up next')).toBeTruthy()
expect(screen.getByText('Queued')).toBeTruthy()
fireEvent.click(screen.getByText('b'))
expect(picked).toBe('beta')
})
})
112 changes: 112 additions & 0 deletions packages/framework-dashboard/components/HotTickets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { HotTicket, HotBucket } from '@gemstack/the-framework'
import { Flame } from 'lucide-react'
import { onHotTickets } from '../server/reads.telefunc.js'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
import { usePolled } from '../lib/use-async.js'
import { cn } from '../lib/utils.js'

// The Overview's "hot tickets" card (#1112): a cross-project glance at what the agent is working on
// (planned/spiked), what is likely next (high priority), and the queued rest. A projection of every
// project's `tickets/` over the `onHotTickets` read, polled so it stays live. Selecting a ticket
// jumps into its project. Advertised on the landing page, so it earns a place on the landing view.

const EMPTY: HotTicket[] = []

// The three lanes, in the order Rom listed them (#1112): worked-on, next, queued. Each carries the
// dot colour that matches the rest of the status vocabulary (primary = active, warning = soon).
// Stacked as full-width sections rather than columns, so an uneven split (the common case, where
// most tickets sit queued) still reads as designed instead of two empty columns.
const LANES: { key: HotBucket; label: string; dot: string }[] = [
{ key: 'in-progress', label: 'In progress', dot: 'bg-primary' },
{ key: 'next', label: 'Up next', dot: 'bg-warning' },
{ key: 'queued', label: 'Queued', dot: 'bg-muted-foreground' },
]

// A lane is capped so the card stays a glance; the rest is summarised as "+N more".
const PER_LANE = 5

export function HotTickets({ onSelectProject }: { onSelectProject: (id: string) => void }) {
const { value: tickets } = usePolled<HotTicket[]>(onHotTickets, EMPTY, 10_000, [])

return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Flame className="h-4 w-4 text-muted-foreground" />
Hot tickets
</CardTitle>
</CardHeader>
<CardContent>
{tickets.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground">No tickets yet.</p>
) : (
<div className="divide-y divide-border">
{LANES.map(lane => (
<Lane key={lane.key} lane={lane} tickets={tickets.filter(t => t.bucket === lane.key)} onSelectProject={onSelectProject} />
))}
</div>
)}
</CardContent>
</Card>
)
}

function Lane({
lane,
tickets,
onSelectProject,
}: {
lane: { key: HotBucket; label: string; dot: string }
tickets: HotTicket[]
onSelectProject: (id: string) => void
}) {
const shown = tickets.slice(0, PER_LANE)
const more = tickets.length - shown.length
const empty = tickets.length === 0
return (
<div className="py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide">
{/* An empty lane dims to a single header line rather than a paragraph, so the populated
lane carries the card and the zeros still say "nothing here" at a glance. */}
<span aria-hidden className={cn('h-2 w-2 shrink-0 rounded-full', lane.dot, empty && 'opacity-40')} />
<span className={empty ? 'text-muted-foreground' : 'text-foreground/80'}>{lane.label}</span>
<span className="tabular-nums text-muted-foreground/70">{tickets.length}</span>
</div>
{!empty && (
<ul className="mt-1.5">
{shown.map(t => (
<li key={`${t.projectId}:${t.ticket.file}`}>
<button
type="button"
onClick={() => onSelectProject(t.projectId)}
title={t.ticket.summary || t.ticket.title}
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"
>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{t.ticket.title}</span>
<TicketTag ticket={t} />
<span className="shrink-0 text-xs text-muted-foreground">{t.projectName}</span>
</button>
</li>
))}
{more > 0 && (
<li className="px-2 pt-0.5 text-xs text-muted-foreground">
+{more} more
</li>
)}
</ul>
)}
</div>
)
}

// The one fact that earns the lane: the plan/spike that made it in-progress, or the priority that
// made it next. Queued rows carry nothing extra — the lane already says it.
function TicketTag({ ticket: t }: { ticket: HotTicket }) {
const tag = t.bucket === 'in-progress' ? (t.ticket.planned ? 'planned' : t.ticket.spiked ? 'spiked' : null) : t.bucket === 'next' ? t.ticket.priority ?? null : null
if (!tag) return null
return (
<span className="shrink-0 rounded border border-border px-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{tag}
</span>
)
}
4 changes: 2 additions & 2 deletions packages/framework-dashboard/server/reads.telefunc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
// Imported then exported, not re-exported (#1014): telefunc's dev transform appends
// `__decorateTelefunction(<name>, ...)` per export, which needs a local binding. An
// `export ... from` creates none, so `pnpm dev` died with `<name> is not defined`.
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'
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'

export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onRecentRuns, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser }
export { onRuns, onRun, onDocs, onProjectLog, onQueue, onOverview, onRecentRuns, onHotTickets, onInterventions, onActivity, onDashboard, onGithubUrl, onGitStatus, onProjectFiles, onProjectFileStatus, onFileDiff, onRunChanges, onFileContent, onTickets, onRetainedWorktrees, onRunWorktree, onRunHandoff, onSystemPromptUser }
2 changes: 1 addition & 1 deletion packages/the-framework/src/dashboard-rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// implementations live here in @gemstack/the-framework so `sendStart` (added with the serve
// wiring) can reach the daemon's `startRun`; the framework-dashboard client imports
// these through thin re-export shims so the baked RPC keys stay `/server/*.telefunc.ts`.
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'
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'
export { sendStop, sendChoice, sendMessage, sendSetHandoff, sendStart, sendPreview, onServeTargets, sendStopPreview, onPreviewStatus, sendOpenInApp, sendRemoveWorktree, sendDeleteSession, sendPushBranch, sendOpenPullRequest, sendQueueTicket, type QueueTicketResult } from './control.telefunc.js'
export { onEvents } from './events.telefunc.js'
export { onProjects, sendAddProject, onOnboarding } from './projects.telefunc.js'
Expand Down
7 changes: 6 additions & 1 deletion packages/the-framework/src/dashboard-rpc/reads.telefunc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { readLogs, type LogEntry } from '../logs.js'
import { readDocs, type WorkspaceDoc } from '../dashboard/docs.js'
import { readTickets, type WorkspaceTicket } from '../dashboard/tickets.js'
import { collectQueue, type ProjectQueue } from '../dashboard/queue.js'
import { buildOverview, buildRecentRuns, type Overview, type RecentRun } from '../dashboard/overview.js'
import { buildOverview, buildRecentRuns, buildHotTickets, type Overview, type RecentRun, type HotTicket } from '../dashboard/overview.js'
import { buildInterventions, type Intervention } from '../dashboard/interventions.js'
import { buildActivity, type Activity } from '../dashboard/activity.js'
import { buildDashboard, type DashboardData } from '../dashboard/dashboard.js'
Expand Down Expand Up @@ -182,6 +182,11 @@ export async function onRecentRuns(): Promise<RecentRun[]> {
return withProjects(projects => buildRecentRuns(projects))
}

/** Hot tickets across every project (#1112): being worked on, likely next, and queued. */
export async function onHotTickets(): Promise<HotTicket[]> {
return withProjects(projects => buildHotTickets(projects))
}

/** The cross-project interventions queue (#632, Queue #624): open PRs that need review, newest first. */
export async function onInterventions(): Promise<Intervention[]> {
return withProjects(buildInterventions)
Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export { serveClientBundle } from './static.js'
export { readDocs, DOC_CATEGORIES, type WorkspaceDoc } from './docs.js'
export { readTickets, type WorkspaceTicket } from './tickets.js'
export { collectQueue, parseTodoItems, type ProjectQueue, type QueueItem } from './queue.js'
export { buildOverview, buildRecentRuns, type Overview, type ActiveRun, type RecentProject, type RecentRun, type OverviewDeps } from './overview.js'
export { buildOverview, buildRecentRuns, buildHotTickets, ticketBucket, type Overview, type ActiveRun, type RecentProject, type RecentRun, type HotTicket, type HotBucket, type OverviewDeps } from './overview.js'
export { buildDashboard, type DashboardData, type ProjectStat, type ActivityDay, type DashboardDeps } from './dashboard.js'
export { readGitStatus, type GitStatus } from './git-status.js'
export { ghPrView, ghPrList, ghJson, nodeGhRunner, type LinkedPr, type OpenPr, type PrLookup, type BranchPrLookup, type PrLister, type GhRunner } from './gh.js'
Expand Down
51 changes: 50 additions & 1 deletion packages/the-framework/src/dashboard/overview.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { buildOverview, buildRecentRuns } from './overview.js'
import { buildOverview, buildRecentRuns, buildHotTickets, ticketBucket } from './overview.js'
import type { ProjectSummary } from './projects.js'
import type { ProjectQueue } from './queue.js'
import type { WorkspaceTicket } from './tickets.js'
import type { RunMeta } from '../store/index.js'

const project = (id: string, path: string, lastActivityAt?: string): ProjectSummary => ({
Expand Down Expand Up @@ -91,3 +92,51 @@ test('buildRecentRuns tolerates a project whose runs cannot be read', async () =
})
assert.deepEqual(recent.map(r => r.run.id), ['x'])
})

const ticket = (file: string, over: Partial<WorkspaceTicket> = {}): WorkspaceTicket => ({
file,
title: file,
summary: '',
spiked: false,
planned: false,
...over,
})

test('ticketBucket: planned/spiked is in-progress, high priority is next, else queued', () => {
assert.equal(ticketBucket(ticket('a', { planned: true })), 'in-progress')
assert.equal(ticketBucket(ticket('b', { spiked: true })), 'in-progress')
assert.equal(ticketBucket(ticket('c', { priority: 'high' })), 'next')
assert.equal(ticketBucket(ticket('d', { priority: 'p1' })), 'next')
assert.equal(ticketBucket(ticket('e')), 'queued')
assert.equal(ticketBucket(ticket('f', { priority: 'low' })), 'queued')
// A planned high-prio ticket is in-progress, not next: work already started outranks the flag.
assert.equal(ticketBucket(ticket('g', { planned: true, priority: 'high' })), 'in-progress')
})

test('buildHotTickets pools every project, buckets each, and orders lane-first', async () => {
const tickets: Record<string, WorkspaceTicket[]> = {
'/a': [ticket('a1.md', { planned: true }), ticket('a2.md', { priority: 'high' })],
'/b': [ticket('b1.md')],
}
const hot = await buildHotTickets([project('alpha', '/a'), project('beta', '/b')], {
tickets: async cwd => tickets[cwd] ?? [],
})
assert.deepEqual(
hot.map(h => ({ p: h.projectName, f: h.ticket.file, b: h.bucket })),
[
{ p: 'alpha', f: 'a1.md', b: 'in-progress' },
{ p: 'alpha', f: 'a2.md', b: 'next' },
{ p: 'beta', f: 'b1.md', b: 'queued' },
],
)
})

test('buildHotTickets tolerates a project whose tickets cannot be read', async () => {
const hot = await buildHotTickets([project('ok', '/ok'), project('bad', '/bad')], {
tickets: async cwd => {
if (cwd === '/bad') throw new Error('unreadable')
return [ticket('x.md', { priority: 'high' })]
},
})
assert.deepEqual(hot.map(h => h.ticket.file), ['x.md'])
})
55 changes: 55 additions & 0 deletions packages/the-framework/src/dashboard/overview.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readAllRuns, readLiveMetas, type LiveRun, type RunMeta, type RunStatus } from '../store/index.js'
import type { ProjectSummary } from './projects.js'
import { collectQueue, type ProjectQueue } from './queue.js'
import { readTickets, type WorkspaceTicket } from './tickets.js'

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

/** Which lane of the "hot tickets" overview (#1112) a ticket sits in. */
export type HotBucket = 'in-progress' | 'next' | 'queued'

/** One ticket surfaced on the Overview's hot-tickets card, tagged with its project and lane. */
export interface HotTicket {
projectId: string
projectName: string
bucket: HotBucket
ticket: WorkspaceTicket
}

/** Priority values that read as "do this soon" — the "likely next" lane (#1112). */
const HIGH_PRIORITY = new Set(['high', 'urgent', 'critical', 'p0', 'p1', '0', '1'])

/**
* A ticket's lane (#1112):
* - in-progress: the agent has planned or spiked it, i.e. work is under way. (There is no run↔ticket
* link, so a ticket being *implemented* right now is only visible through the plan/spike it left.)
* - next: no plan/spike yet, but flagged high priority — likely the next thing picked up.
* - queued: everything else open, the backlog waiting its turn.
*/
export function ticketBucket(ticket: WorkspaceTicket): HotBucket {
if (ticket.planned || ticket.spiked) return 'in-progress'
if (ticket.priority && HIGH_PRIORITY.has(ticket.priority)) return 'next'
return 'queued'
}

/** How many hot tickets the Overview pools before the card trims per lane. */
const HOT_TICKETS_LIMIT = 60

/** Injectable reader so {@link buildHotTickets} is unit-testable off disk. */
export interface HotTicketsDeps {
tickets?: (cwd: string) => Promise<WorkspaceTicket[]>
}

/**
* Every project's tickets pooled and bucketed for the Overview's "hot tickets" card (#1112): what
* is being worked on (planned/spiked), what is likely next (high priority), and the queued rest.
* Ordered lane-first (in-progress, then next, then queued), file order within a lane. Forgiving —
* a project whose tickets cannot be read simply contributes nothing.
*/
export async function buildHotTickets(projects: ProjectSummary[], deps: HotTicketsDeps = {}): Promise<HotTicket[]> {
const readT = deps.tickets ?? readTickets
const all: HotTicket[] = []
for (const project of projects) {
for (const ticket of await readT(project.path).catch(() => [])) {
all.push({ projectId: project.id, projectName: project.name, bucket: ticketBucket(ticket), ticket })
}
}
const lane: Record<HotBucket, number> = { 'in-progress': 0, next: 1, queued: 2 }
all.sort((a, b) => lane[a.bucket] - lane[b.bucket])
return all.slice(0, HOT_TICKETS_LIMIT)
}

/** Injectable readers so {@link buildOverview} is unit-testable off disk. */
export interface OverviewDeps {
liveRuns?: (cwd: string) => Promise<LiveRun[]>
Expand Down
Loading
Loading