{files.map(file => (
-
onRemove(file)}
- title={`Remove ${file}`}
- aria-label={`Remove ${file} from context`}
- className="flex h-3.5 w-3.5 items-center justify-center rounded text-muted-foreground hover:text-danger disabled:opacity-50"
- >
-
-
+
+ onRemove(file)}
+ aria-label={`Remove ${file} from context`}
+ className="flex h-3.5 w-3.5 items-center justify-center rounded text-muted-foreground hover:text-danger disabled:opacity-50"
+ />
+ }
+ >
+
+
+ Remove {file}
+
{file}
diff --git a/packages/framework-dashboard/components/ContextMenu.tsx b/packages/framework-dashboard/components/ContextMenu.tsx
index a0e16002..9d07104d 100644
--- a/packages/framework-dashboard/components/ContextMenu.tsx
+++ b/packages/framework-dashboard/components/ContextMenu.tsx
@@ -3,6 +3,7 @@ import { Layers } from 'lucide-react'
import { cn } from '../lib/utils.js'
import { buttonVariants } from './ui/button.js'
import { ContextFiles } from './ContextFiles.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -39,35 +40,49 @@ export function ContextMenu({
}) {
return (
-
-
- Context{summary && · {summary} }
-
+
+
+ }
+ >
+
+ Context{summary && · {summary} }
+
+ Narrow the run to specific repos and files
+
-
- Projects
-
+
+ }>Projects
+
+ The agent can still reach every repo; ticking some just narrows its focus.
+
+
{otherProjects.length > 0 ? (
otherProjects.map(p => (
- onToggle(p.path)}
- disabled={busy}
- title={p.path}
- >
- {p.name}
-
+
+ onToggle(p.path)}
+ disabled={busy}
+ />
+ }
+ >
+ {p.name}
+
+ {p.path}
+
))
) : (
No other repos to add.
diff --git a/packages/framework-dashboard/components/DashboardPage.tsx b/packages/framework-dashboard/components/DashboardPage.tsx
index f8b4f121..7c885b08 100644
--- a/packages/framework-dashboard/components/DashboardPage.tsx
+++ b/packages/framework-dashboard/components/DashboardPage.tsx
@@ -1,28 +1,30 @@
-import { type ReactNode } from 'react'
-import type { DashboardData, ActiveRun, ProjectStat, ProjectQueue, Intervention } from '@gemstack/the-framework'
-import { FolderGit2, Zap, ListChecks, History, GitBranch, GitPullRequest, Inbox, MessageCircleQuestion } from 'lucide-react'
+import type { DashboardData, ActiveRun, ProjectQueue, RecentRun, Intervention } from '@gemstack/the-framework'
+import { Bot, GitBranch, GitPullRequest, Inbox, ListTodo, MessageCircleQuestion } from 'lucide-react'
import { onDashboard } from '../server/reads.telefunc.js'
import { interventionKey } from '@gemstack/the-framework/client'
-import { ActivityChart } from './ActivityChart.js'
-import { RunOutcomes } from './RunOutcomes.js'
import { Quota } from './Quota.js'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.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 { WorkingNow } from './WorkingNow.js'
import { RoutineWork } from './RoutineWork.js'
-import { cn } from '../lib/utils.js'
import { queueEntryLabel } from '../lib/queue-entry.js'
-import { formatDateTime, formatRelative } from '../lib/format-date.js'
+import { runLabel } from '../lib/run-label.js'
+import { formatAge, formatDateTime } from '../lib/format-date.js'
import { ScrollArea } from './ui/scroll-area.js'
-// The Overview dashboard page (#471). What used to be a cramped, collapsible section in the
-// first sidebar is now a proper at-a-glance landing: KPI tiles, a two-week activity chart,
-// how past runs ended, what the agent is working on right now, and the TODO backlog — all a
-// projection of the same files over the `onDashboard` Telefunc read, polled so it stays live.
-// Selecting anything here jumps into that project. Shown by the shell when no project is picked.
+// The Overview landing page (#1139): a focused at-a-glance board — usage first, then the two queues
+// (what needs a human, what the AI takes up next) side by side, the routine jobs, the agents working
+// now and just finished, and the hot tickets across every project. Each section is a projection of
+// the same .the-framework files over the `onDashboard` Telefunc read, polled so it stays live;
+// selecting a row jumps into its project or straight into a session. Shown by the shell when no
+// project is picked.
+//
+// It replaced the denser board this started as (#471) — KPI tiles, a two-week activity chart, run
+// outcomes, and a projects table — cut here as redundant (#1139); the activity chart is meant to
+// return later.
export function DashboardPage({
onSelectProject,
onSelectRun,
@@ -30,7 +32,7 @@ export function DashboardPage({
interventions,
}: {
onSelectProject: (id: string) => void
- /** Where a row that names one session lands: on that session, rather than its project's launcher. */
+ /** Open one session (project + run): the Agents and hot-ticket rows link straight to a session. */
onSelectRun: (projectId: string, runId: string) => void
/** Where a session the onboarding checklist starts lands (#1169): on that session. */
onRunStarted: (projectId: string, intent: string, runId?: string) => void
@@ -40,106 +42,40 @@ export function DashboardPage({
// Dismissing only hides it here (#958); the settings page keeps it, which is what the
// dismiss control says.
const onboardingDismissed = usePreferences().onboardingDismissed ?? false
+ const loading = data === null
return (
-
-
Overview
-
-
{!onboardingDismissed &&
}
-
+ {/* Usage first (#1139): the one figure that governs everything the agent may do next. */}
+
-
+ {/* The two queues side by side (#1139): what needs you, and what the AI takes up next. */}
+
- {/* The scheduled jobs, and the button that fires one now (#1159). Above the tiles: it is
- something to do, and the rest of the page is something to read. */}
+ {/* Routine work sits below the AI Queue (#1139/#1159): the scheduled jobs and the button
+ that fires one now. */}
- {data === null ? (
-
Loading…
- ) : (
- <>
-
- } label="Projects" value={data.totals.projects} />
- }
- label="Active sessions"
- value={data.totals.activeRuns}
- accent={data.totals.activeRuns > 0}
- />
- } label="Open TODOs" value={data.totals.openTodos} />
- } label="Total sessions" value={data.totals.totalRuns} />
-
-
- {/* Two column stacks rather than two rows of two. As rows, each card was stretched to
- its neighbour's height, so a quiet board left half of "Session outcomes" and most of
- "Working now" as empty card. Stacking lets every card size to its content and pairs
- the tall chart against the tall list, so the columns come out level. */}
-
-
-
-
- Session activity
-
-
-
-
-
-
-
- Working now
-
-
-
-
-
-
-
-
-
- Session outcomes
-
-
-
-
-
-
-
- AI Queue
-
-
-
-
-
-
-
-
-
-
- Projects
-
-
-
-
-
+
-
- >
- )}
+
)
}
-// The Queue's "needs you" list (#632, part of #624): the cross-project interventions. Two kinds:
-// open PRs to review (proposals + finished work both surface as PRs) — merge to confirm, close to
-// reject, so each links straight out to its PR; and runs paused mid-flight on a question (#636),
-// which jump into that project's live view to answer. Placed at the top of the Overview as the
-// day's actionable inbox; #627 notifications fire off the same set.
-function NeedsYou({ items, onSelectProject }: { items: Intervention[]; onSelectProject: (id: string) => void }) {
+// The Human Queue (#632/#1139): the cross-project things only a person can clear. Three kinds: open
+// PRs to review (proposals and finished work both surface as PRs) — merge to confirm, close to
+// reject, so each links straight out to its PR; runs paused mid-flight on a question (#636), which
+// jump into that project's live view to answer; and work a finished session left unpushed. #627
+// notifications fire off the same set.
+function HumanQueue({ items, onSelectProject }: { items: Intervention[]; onSelectProject: (id: string) => void }) {
return (
@@ -152,57 +88,76 @@ function NeedsYou({ items, onSelectProject }: { items: Intervention[]; onSelectP
)}
+ Agents awaiting your approval, review, or input
{items.length === 0 ? (
- You're all caught up — nothing to review.
+ AI doesn't need you.
) : (
{items.map(item => (
{item.kind === 'awaiting' ? (
- onSelectProject(item.projectId)}
- className="flex w-full items-center gap-2 py-2 text-left hover:opacity-80"
- title="Open the session to answer"
- >
-
- Awaiting
- {item.title}
- {item.projectName}
-
+
+ onSelectProject(item.projectId)}
+ className="flex w-full items-center gap-2 py-2 text-left hover:opacity-80"
+ />
+ }
+ >
+
+ Awaiting
+ {item.title}
+ {item.projectName}
+
+ Open the session to answer
+
) : item.kind === 'unpushed' ? (
- onSelectProject(item.projectId)}
- className="flex w-full items-center gap-2 py-2 text-left hover:opacity-80"
- title={`Open the session: work on ${item.branch ?? ''} was never pushed`}
- >
-
- Unpushed
- {item.title}
- {/* An unknown count says nothing rather than the contradictory "0 commits". */}
- {item.commits !== undefined && item.commits > 0 && (
-
- {item.commits === 1 ? '1 commit' : `${item.commits} commits`}
-
- )}
- {item.projectName}
-
+
+ onSelectProject(item.projectId)}
+ className="flex w-full items-center gap-2 py-2 text-left hover:opacity-80"
+ />
+ }
+ >
+
+ Unpushed
+ {item.title}
+ {/* An unknown count says nothing rather than the contradictory "0 commits". */}
+ {item.commits !== undefined && item.commits > 0 && (
+
+ {item.commits === 1 ? '1 commit' : `${item.commits} commits`}
+
+ )}
+ {item.projectName}
+
+ {`Open the session: work on ${item.branch ?? ''} was never pushed`}
+
) : (
-
-
- #{item.number}
- {item.title}
- {item.projectName}
-
+
+
+ }
+ >
+
+ #{item.number}
+ {item.title}
+ {item.projectName}
+
+ {`Open PR #${item.number} on GitHub`}
+
)}
))}
@@ -213,125 +168,186 @@ function NeedsYou({ items, onSelectProject }: { items: Intervention[]; onSelectP
)
}
-function StatTile({
- icon,
- label,
- value,
- accent = false,
+// The AI Queue (#1139): every project's open `TODO_AGENTS.md` items — the work the framework picks
+// up on its own — grouped by project and shown in full. No "+N more": this is the plan, and a
+// collapsed plan is one you cannot read. Bullets, not checkboxes: nothing here is yours to tick off.
+function AiQueue({
+ queue,
+ loading,
+ onSelectProject,
}: {
- icon: ReactNode
- label: string
- value: number
- accent?: boolean
+ queue: ProjectQueue[]
+ loading: boolean
+ onSelectProject: (id: string) => void
}) {
+ const withOpen = queue.filter(q => q.open > 0)
return (
-
-
- {icon}
- {label}
-
- {value}
+
+
+
+ AI Queue
+
+ Tasks AI will work on next
+
+
+ {loading ? (
+ Loading…
+ ) : withOpen.length === 0 ? (
+ Nothing queued.
+ ) : (
+
+ {withOpen.map(q => (
+
+ onSelectProject(q.projectId)}
+ className="flex w-full items-center gap-2 text-left hover:opacity-80"
+ >
+ {q.projectName}
+ {q.open}
+
+
+ {q.items
+ .filter(i => !i.done)
+ .map((item, i) => {
+ // The line is markdown, and a queued ticket is written as a link to it (#1164),
+ // so print the title rather than the source; the whole line stays in the tooltip.
+ const label = queueEntryLabel(item.text)
+ return (
+
+ •
+ {label.text}
+
+ )
+ })}
+
+
+ ))}
+
+ )}
)
}
-function Backlog({ queue, onSelectProject }: { queue: ProjectQueue[]; onSelectProject: (id: string) => void }) {
- const withOpen = queue.filter(q => q.open > 0)
- if (withOpen.length === 0) {
- return No open TODOs.
- }
+/** One agent row's data, shared by the Current and Recent columns. */
+interface AgentRowData {
+ key: string
+ /** The session's one-liner, the same the sidebar shows. */
+ label: string
+ /** ISO: last activity for a working agent, finish time for a finished one. */
+ at: string | undefined
+ projectName: string
+ onOpen: () => void
+}
+
+// The Agents view (#1139): sessions working now (Current) and just finished (Recent), side by side.
+// Each row is the whole line, clickable straight into that session — its label is the one-liner the
+// sidebar shows (runLabel), and its age reads "22s ago" with the exact moment on hover. This is what
+// replaced the old "Working now" list.
+function Agents({
+ working,
+ finished,
+ loading,
+ onSelectRun,
+}: {
+ working: ActiveRun[]
+ finished: RecentRun[]
+ loading: boolean
+ onSelectRun: (projectId: string, runId: string) => void
+}) {
+ const current: AgentRowData[] = working.map(a => ({
+ key: `${a.projectId}:${a.runId}`,
+ label: activeLabel(a),
+ at: a.updatedAt,
+ projectName: a.projectName,
+ onOpen: () => onSelectRun(a.projectId, a.runId),
+ }))
+ const recent: AgentRowData[] = finished.map(f => ({
+ key: `${f.projectId}:${f.run.id}`,
+ label: runLabel(f.run),
+ at: f.run.updatedAt,
+ projectName: f.projectName,
+ onOpen: () => onSelectRun(f.projectId, f.run.id),
+ }))
return (
-
- {withOpen.map(q => (
-
- onSelectProject(q.projectId)}
- className="flex w-full items-center gap-2 text-left hover:opacity-80"
- >
- {q.projectName}
- {q.open}
-
-
- {q.items
- .filter(i => !i.done)
- .slice(0, 3)
- .map((item, i) => {
- // The line is markdown, and a queued ticket is written as a link to it (#1164), so
- // print the title rather than the source. The whole line stays in the tooltip: the
- // agent's note after the link is worth having, just not at the cost of the title.
- const label = queueEntryLabel(item.text)
- return (
-
- ▢
- {label.text}
-
- )
- })}
- {q.open > 3 && +{q.open - 3} more }
-
-
- ))}
-
+
+
+
+
+ Agents
+
+
+
+
+
+
)
}
-function ProjectsTable({ projects, onSelectProject }: { projects: ProjectStat[]; onSelectProject: (id: string) => void }) {
- if (projects.length === 0) {
- return No projects yet.
- }
+function AgentColumn({
+ heading,
+ description,
+ rows,
+ loading,
+ empty,
+}: {
+ heading: string
+ description: string
+ rows: AgentRowData[]
+ loading: boolean
+ empty: string
+}) {
return (
-
-
-
-
- Project
- Sessions
- TODOs
- Last activity
-
-
-
- {projects.map(p => (
- onSelectProject(p.projectId)}
- onKeyDown={e => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault()
- onSelectProject(p.projectId)
- }
- }}
- className="cursor-pointer border-b border-border/60 last:border-0 hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
- >
-
-
- {/* Decorative dot + sr-only status (#695/U33): color alone reaches no screen reader. */}
-
- {p.running ? 'Running' : p.activated ? 'Activated' : 'Not activated'}:
- {p.projectName}
-
-
- {p.runs}
- {p.openTodos || '—'}
-
- {formatRelative(p.lastActivityAt)}
-
-
+
+
{heading}
+
{description}
+ {loading ? (
+
Loading…
+ ) : rows.length === 0 ? (
+
{empty}
+ ) : (
+
+ {rows.map(r => (
+
))}
-
-
+
+ )}
)
}
+
+function AgentRow({ label, at, projectName, onOpen }: Omit) {
+ return (
+
+
+ •
+ {label}
+ {projectName}
+ {at && (
+
+ }>
+ {formatAge(at)}
+
+ {formatDateTime(at)}
+
+ )}
+
+
+ )
+}
+
+// A working session's one-liner: what the sidebar would show for it. ActiveRun carries no branch or
+// start time to fall back to, but a live session almost always has an intent or a chosen name; its
+// project is the last resort.
+function activeLabel(a: ActiveRun): string {
+ return a.intent?.trim() || a.sessionName?.trim() || a.scope?.trim() || a.projectName
+}
diff --git a/packages/framework-dashboard/components/DevicesSettings.tsx b/packages/framework-dashboard/components/DevicesSettings.tsx
index 937514ec..542508ee 100644
--- a/packages/framework-dashboard/components/DevicesSettings.tsx
+++ b/packages/framework-dashboard/components/DevicesSettings.tsx
@@ -6,6 +6,7 @@ import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-tar
import { AddDeviceDialog } from './AddDeviceDialog.js'
import { Button } from './ui/button.js'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import { cn } from '../lib/utils.js'
// Saved devices, as a settings section (#1052/#1072).
@@ -62,15 +63,21 @@ export function DevicesSettings() {
- remove(profile)}
- title={`Remove ${profile.label}`}
- aria-label={`Remove ${profile.label}`}
- >
-
-
+
+ remove(profile)}
+ aria-label={`Remove ${profile.label}`}
+ />
+ }
+ >
+
+
+ Remove {profile.label}
+
))}
diff --git a/packages/framework-dashboard/components/EventList.tsx b/packages/framework-dashboard/components/EventList.tsx
index 346d0a6f..274302e2 100644
--- a/packages/framework-dashboard/components/EventList.tsx
+++ b/packages/framework-dashboard/components/EventList.tsx
@@ -5,6 +5,7 @@ import { eventKindLabel } from '../lib/event-labels.js'
import { receivedAt } from '../lib/event-times.js'
import { Markdown } from './Markdown.js'
import { Badge } from './ui/badge.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import {
MessageScroller,
MessageScrollerButton,
@@ -146,9 +147,14 @@ export function EventList({
{(formatFrameworkEvent(e) ?? '').trim()}
)}
{chunkHead && at !== undefined && (
-
- {formatTime(at)}
-
+
+ }
+ >
+ {formatTime(at)}
+
+ {new Date(at).toLocaleString()}
+
)}
)
diff --git a/packages/framework-dashboard/components/FileTree.tsx b/packages/framework-dashboard/components/FileTree.tsx
index cae361e4..237e5b54 100644
--- a/packages/framework-dashboard/components/FileTree.tsx
+++ b/packages/framework-dashboard/components/FileTree.tsx
@@ -125,10 +125,11 @@ export function FileTree({
const name = path.slice(path.lastIndexOf('/') + 1)
const isOn = selected.has(path)
const git = status[path]
+ // No `title`: the hover preview card already leads with the full path, and a native
+ // tooltip on top of it is the slow system one the dashboard no longer uses (#1149).
const item = (
onToggle(path)}
{...(git ? { gitStatus: git } : {})}
diff --git a/packages/framework-dashboard/components/GitStatusBar.tsx b/packages/framework-dashboard/components/GitStatusBar.tsx
index 3a50441c..64356eaa 100644
--- a/packages/framework-dashboard/components/GitStatusBar.tsx
+++ b/packages/framework-dashboard/components/GitStatusBar.tsx
@@ -5,6 +5,7 @@ import { onGitStatus, onRunWorktree } from '../server/reads.telefunc.js'
import { usePolled } from '../lib/use-async.js'
import { formatBytes } from '@gemstack/the-framework/client'
import { cn } from '../lib/utils.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
// The checkout in play (#491, part of #488): active branch, a clean/dirty dot, the linked PR.
// Polled, so it tracks a run committing or branching. Hidden when there is no git repo (or on
@@ -124,30 +125,37 @@ export function GitStatusBar({
)}
>
-
- {branchText}
-
-
-
- {/* Clean is neutral, not green. Green means "added / new / done" everywhere else, so a
- green dot for "nothing changed" sat one pane away from the file tree's green dot for
- "this folder HAS changes": the same colour for opposite facts. A clean tree is the
- unremarkable default and has nothing to announce. */}
-
- {status.dirty ? 'dirty' : 'clean'}
+
+
+ }
+ >
+ {branchText}
+
+ {branchTitle}
+
+ {/* Clean is neutral, not green. Green means "added / new / done" everywhere else, so a
+ green dot for "nothing changed" sat one pane away from the file tree's green dot for
+ "this folder HAS changes": the same colour for opposite facts. A clean tree is the
+ unremarkable default and has nothing to announce. */}
+
+ }>
+
+ {status.dirty ? 'dirty' : 'clean'}
+
+ {status.dirty ? dirtyLabel : 'Clean'}
+
{runState}
{/* Only a worktree has a size worth showing, and only once nothing is writing to it (#798). */}
{size && (
-
- {size}
-
+
+ }>
+ {size}
+
+ This session's worktree on disk
+
)}
{/* The branch is the only part that gives up width (#1026): it truncates with an ellipsis
and still reads, where a half-cut "0 files · me" does not. The facts furthest from the
@@ -173,18 +181,24 @@ export function GitStatusBar({
facts
)}
{status.pr && (
-
- PR #{status.pr.number}
-
- {status.pr.state.toLowerCase()}
-
-
+
+
+ }
+ >
+ PR #{status.pr.number}
+
+ {status.pr.state.toLowerCase()}
+
+
+ {status.pr.title}
+
)}
>
)
diff --git a/packages/framework-dashboard/components/HotTickets.test.tsx b/packages/framework-dashboard/components/HotTickets.test.tsx
index df69339b..bc01d650 100644
--- a/packages/framework-dashboard/components/HotTickets.test.tsx
+++ b/packages/framework-dashboard/components/HotTickets.test.tsx
@@ -28,15 +28,15 @@ describe('HotTickets (#1112)', () => {
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'),
+ ht('b.md', 'beta', 'high-priority', { priority: 'high' }),
+ ht('c.md', 'alpha', 'ai-queue'),
])
let picked: string | null = null
render( (picked = id)} onSelectRun={vi.fn()} />)
await waitFor(() => expect(screen.getByText('a')).toBeTruthy())
expect(screen.getByText('In progress')).toBeTruthy()
- expect(screen.getByText('Up next')).toBeTruthy()
- expect(screen.getByText('Queued')).toBeTruthy()
+ expect(screen.getByText('AI Queue')).toBeTruthy()
+ expect(screen.getByText('High priority')).toBeTruthy()
fireEvent.click(screen.getByText('b'))
expect(picked).toBe('beta')
})
@@ -76,7 +76,7 @@ describe('a hot ticket that names a run opens that run', () => {
})
test('a ticket with no run still goes to its project', async () => {
- onHotTickets.mockResolvedValue([ht('b.md', 'beta', 'next')])
+ onHotTickets.mockResolvedValue([ht('b.md', 'beta', 'ai-queue')])
const onSelectRun = vi.fn()
const onSelectProject = vi.fn()
render( )
diff --git a/packages/framework-dashboard/components/HotTickets.tsx b/packages/framework-dashboard/components/HotTickets.tsx
index e7002e69..78c8a141 100644
--- a/packages/framework-dashboard/components/HotTickets.tsx
+++ b/packages/framework-dashboard/components/HotTickets.tsx
@@ -2,29 +2,27 @@ 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 { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.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.
+// The Overview's "hot tickets" card (#1139): a cross-project glance at what the agent is working on
+// (in progress), what sits in the AI Queue, and what is flagged high priority — nothing else. A
+// projection of every project's `tickets/` + `TODO_AGENTS.md` over the `onHotTickets` read, polled
+// so it stays live. Selecting a ticket jumps into its project.
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.
+// The three lanes the card shows (#1139): in progress, then the AI Queue, then high priority. Each
+// carries the dot colour that matches the rest of the status vocabulary (primary = active, warning =
+// queued, info = flagged). Laid out in two columns rather than stacked full-width, so the card stays
+// compact even when one lane runs long.
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' },
+ { key: 'ai-queue', label: 'AI Queue', dot: 'bg-warning' },
+ { key: 'high-priority', label: 'High priority', dot: 'bg-info' },
]
-// 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,
onSelectRun,
@@ -47,7 +45,7 @@ export function HotTickets({
{tickets.length === 0 ? (
No tickets yet.
) : (
-
+
{LANES.map(lane => (
void
onSelectRun: (id: string, runId: string) => void
}) {
- const shown = tickets.slice(0, PER_LANE)
- const more = tickets.length - shown.length
const empty = tickets.length === 0
return (
-
+
{/* 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. */}
@@ -88,28 +84,31 @@ function Lane({
{tickets.length}
{!empty && (
+ // Every ticket in the lane, never a "+N more" — the card is the shortlist, and a lane you
+ // cannot read past is one you have to leave the page to act on.
- {shown.map(t => (
+ {tickets.map(t => (
- (t.runId ? onSelectRun(t.projectId, t.runId) : 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"
- >
- {t.ticket.title}
-
- {t.projectName}
-
+
+ (t.runId ? onSelectRun(t.projectId, t.runId) : onSelectProject(t.projectId))}
+ 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"
+ />
+ }
+ >
+ {t.ticket.title}
+
+ {t.projectName}
+
+ {t.ticket.summary || t.ticket.title}
+
))}
- {more > 0 && (
-
- +{more} more
-
- )}
)}
@@ -117,8 +116,8 @@ function Lane({
}
// The one fact that earns the lane: a run implementing it right now, else the plan/spike that made
-// it in-progress, else the priority that made it next. Queued rows carry nothing extra — the lane
-// already says it.
+// it in-progress, else the priority that put it in the high-priority lane. AI-Queue rows carry
+// nothing extra — the lane already says it.
//
// `implementing` is coloured rather than muted like the others (#1117), because it is the only tag
// that describes something happening as you read it: `planned` and `spiked` are marks work left
@@ -131,7 +130,16 @@ 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
+ const tag =
+ t.bucket === 'in-progress'
+ ? t.ticket.planned
+ ? 'planned'
+ : t.ticket.spiked
+ ? 'spiked'
+ : null
+ : t.bucket === 'high-priority'
+ ? t.ticket.priority ?? null
+ : null
if (!tag) return null
return (
diff --git a/packages/framework-dashboard/components/NotificationsMenu.test.tsx b/packages/framework-dashboard/components/NotificationsMenu.test.tsx
index 86de85a3..48d88a48 100644
--- a/packages/framework-dashboard/components/NotificationsMenu.test.tsx
+++ b/packages/framework-dashboard/components/NotificationsMenu.test.tsx
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { Preferences } from '@gemstack/the-framework'
+import { hoverTooltip } from '../test-utils.js'
const updatePreferences = vi.hoisted(() => vi.fn())
// The daemon's channel capability (#948), read through the shared store since #1095: both
@@ -34,7 +35,10 @@ afterEach(() => {
vi.unstubAllGlobals()
})
-const open = () => fireEvent.click(screen.getByRole('button', { name: /notifications/i }))
+const bell = () => screen.getByRole('button', { name: /notifications/i })
+const open = () => fireEvent.click(bell())
+/** What the bell says on hover — the state read-out lives in its tooltip now (#1149). */
+const bellTooltip = async () => (await hoverTooltip(bell())).textContent
describe('NotificationsMenu (#676)', () => {
test('the popover groups methods and categories, both "Human Queue" and "New activity" toggleable', () => {
@@ -71,12 +75,12 @@ describe('NotificationsMenu (#676)', () => {
expect((globalThis.Notification as unknown as { requestPermission: () => void }).requestPermission).toHaveBeenCalled()
})
- test('the bell reads active when a granted method is on, idle otherwise', () => {
+ test('the bell reads active when a granted method is on, idle otherwise', async () => {
const { rerender } = render( ) // default: browser on + granted
- expect(screen.getByRole('button', { name: /notifications/i }).getAttribute('title')).toBe('Notifications on')
+ expect(await bellTooltip()).toBe('Notifications on')
prefs = { notifyBrowser: false } // no method effectively on
rerender( )
- expect(screen.getByRole('button', { name: /notifications/i }).getAttribute('title')).toBe('Notifications')
+ expect(await bellTooltip()).toBe('Notifications')
})
test('a blocked browser permission disables the Browser toggle with a hint', () => {
@@ -105,10 +109,10 @@ describe('NotificationsMenu (#676)', () => {
expect(updatePreferences).toHaveBeenCalledWith({ discordBot: true })
})
- test('turning the bot on does not light the bell, which is about notifications (#916)', () => {
+ test('turning the bot on does not light the bell, which is about notifications (#916)', async () => {
prefs = { discordBot: true, notifyBrowser: false, notifyDiscord: false }
render( )
- expect(screen.getByRole('button', { name: /notifications/i }).getAttribute('title')).toBe('Notifications')
+ expect(await bellTooltip()).toBe('Notifications')
})
// #948: the toggle is a preference; delivery needs the daemon env var. An unconfigured
@@ -117,9 +121,7 @@ describe('NotificationsMenu (#676)', () => {
channels.value = { discordWebhook: false, discordBot: false, sources: {}, editable: true }
prefs = { notifyDiscord: true, notifyBrowser: false }
render( )
- await waitFor(() =>
- expect(screen.getByRole('button', { name: /notifications/i }).getAttribute('title')).toBe('Notifications'),
- )
+ await waitFor(async () => expect(await bellTooltip()).toBe('Notifications'))
open()
expect(screen.getByText('Not configured — add a webhook in Settings')).toBeTruthy()
expect(screen.getByText('Not configured — add a bot token in Settings')).toBeTruthy()
@@ -128,8 +130,6 @@ describe('NotificationsMenu (#676)', () => {
test('a configured webhook keeps the bell lit for Discord delivery', async () => {
prefs = { notifyDiscord: true, notifyBrowser: false }
render( )
- await waitFor(() =>
- expect(screen.getByRole('button', { name: /notifications/i }).getAttribute('title')).toBe('Notifications on'),
- )
+ await waitFor(async () => expect(await bellTooltip()).toBe('Notifications on'))
})
})
diff --git a/packages/framework-dashboard/components/NotificationsMenu.tsx b/packages/framework-dashboard/components/NotificationsMenu.tsx
index 6e922d05..8aace0ff 100644
--- a/packages/framework-dashboard/components/NotificationsMenu.tsx
+++ b/packages/framework-dashboard/components/NotificationsMenu.tsx
@@ -4,6 +4,7 @@ import { usePreferences, updatePreferences, notificationsEnabled, discordEnabled
import { useNotifyChannels } from '../lib/notify-channels.js'
import { cn } from '../lib/utils.js'
import { OptionLabel } from './OptionsMenu.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -59,20 +60,26 @@ export function NotificationsMenu() {
return (
-
- {anyActive ? : }
- {anyActive && (
-
- )}
-
+
+
+ }
+ >
+ {anyActive ? : }
+ {anyActive && (
+
+ )}
+
+ {anyActive ? 'Notifications on' : 'Notifications'}
+
Deliver to
@@ -81,7 +88,6 @@ export function NotificationsMenu() {
checked={browser}
disabled={blocked}
onCheckedChange={toggleBrowser}
- title={browserHint}
className="items-start"
>
@@ -90,7 +96,6 @@ export function NotificationsMenu() {
updatePreferences({ notifyDiscord: next })}
- title={webhookReady ? 'Reaches you with no dashboard open' : 'Add a webhook on the settings page to deliver here'}
className="items-start"
>
updatePreferences({ notifyHumanIntervention: next })}
- title="A session awaiting your answer, or a PR ready to review"
className="items-start"
>
@@ -115,7 +119,6 @@ export function NotificationsMenu() {
updatePreferences({ notifyNewActivity: next })}
- title="Also ping when a session starts or finishes, not just when something needs you"
className="items-start"
>
@@ -129,7 +132,6 @@ export function NotificationsMenu() {
updatePreferences({ discordBot: next })}
- title={botReady ? 'Lets Discord messages start and steer sessions' : 'Add a bot token on the settings page to enable the bot'}
className="items-start"
>
{suggestion?.cwd && !suggestion.cwdProjectId && (
-
+
{addingCwd ? 'Adding…' : `Add ${suggestion.cwd} as project`}
)}
@@ -209,15 +210,21 @@ export function OnboardingChecklist({
{dismissible && (
- updatePreferences({ onboardingDismissed: true })}
- title="Remove, you can resume the onboarding on the settings page"
- aria-label="Remove, you can resume the onboarding on the settings page"
- >
-
-
+
+ updatePreferences({ onboardingDismissed: true })}
+ aria-label="Remove, you can resume the onboarding on the settings page"
+ />
+ }
+ >
+
+
+ Remove, you can resume the onboarding on the settings page
+
)}
diff --git a/packages/framework-dashboard/components/OptionsMenu.test.tsx b/packages/framework-dashboard/components/OptionsMenu.test.tsx
index cf3fbd4e..bec3baa4 100644
--- a/packages/framework-dashboard/components/OptionsMenu.test.tsx
+++ b/packages/framework-dashboard/components/OptionsMenu.test.tsx
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { OptionRow, ConnectionControl, RunTarget } from './OptionsMenu.js'
import type { ConnectionProfile } from '../lib/profiles.js'
+import { hoverTooltip } from '../test-utils.js'
const updatePreferences = vi.hoisted(() => vi.fn())
vi.mock('../lib/preferences.js', () => ({ updatePreferences }))
@@ -24,11 +25,11 @@ function open() {
}
describe('OptionsMenu (#654)', () => {
- test('the trigger marks that options are on with a dot (#1046)', () => {
+ test('the trigger marks that options are on with a dot (#1046)', async () => {
render( )
const trigger = screen.getByRole('button', { name: /session options/i })
- // A presence dot now, not a number: the count lives in the title for a11y, the dot in the corner.
- expect(trigger.getAttribute('title')).toMatch(/\bon$/)
+ // A presence dot now, not a number: the count is what the tooltip says, the dot in the corner.
+ expect((await hoverTooltip(trigger)).textContent).toMatch(/\bon$/)
expect(trigger.querySelector('span.rounded-full')).not.toBeNull()
expect(trigger.textContent).not.toContain('1') // no number in the badge anymore
})
diff --git a/packages/framework-dashboard/components/OptionsMenu.tsx b/packages/framework-dashboard/components/OptionsMenu.tsx
index 34a3146b..51736ec5 100644
--- a/packages/framework-dashboard/components/OptionsMenu.tsx
+++ b/packages/framework-dashboard/components/OptionsMenu.tsx
@@ -6,6 +6,7 @@ import type { ConnectionProfile } from '../lib/profiles.js'
import type { DeviceStatus } from '../lib/use-device-status.js'
import { cn } from '../lib/utils.js'
import { buttonVariants } from './ui/button.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -160,19 +161,25 @@ function RunTargetSub({ control, connection, busy }: { control: RunTargetControl
- {
- // Remove, not select: keep the row's own click out of it (#1072).
- e.stopPropagation()
- connection.onRemove(profile)
- }}
- title={`Remove ${profile.label}`}
- aria-label={`Remove device ${profile.label}`}
- className="mt-0.5 rounded p-0.5 text-[var(--color-muted-foreground)] hover:text-danger"
- >
-
-
+
+ {
+ // Remove, not select: keep the row's own click out of it (#1072).
+ e.stopPropagation()
+ connection.onRemove(profile)
+ }}
+ aria-label={`Remove device ${profile.label}`}
+ className="mt-0.5 rounded p-0.5 text-[var(--color-muted-foreground)] hover:text-danger"
+ />
+ }
+ >
+
+
+ Remove {profile.label}
+
)
})}
@@ -187,22 +194,28 @@ function RunTargetSub({ control, connection, busy }: { control: RunTargetControl
)
}
-/** One preference checkbox row. The disabled reason rides the description (the `title`
- * tooltip is suppressed on disabled dropdown items), so a greyed row isn't a mystery. */
+/** One preference checkbox row. The disabled reason rides the description (the tooltip
+ * does not open on disabled dropdown items), so a greyed row isn't a mystery. */
function OptionCheckboxRow({ row, busy, indent = false }: { row: OptionRow; busy: boolean; indent?: boolean }) {
return (
- setOption(row.key, checked)}
- title={row.title}
- className={indent ? 'items-start pl-8' : 'items-start'}
- >
-
-
+
+ setOption(row.key, checked)}
+ className={indent ? 'items-start pl-8' : 'items-start'}
+ />
+ }
+ >
+
+
+ {row.title}
+
)
}
@@ -233,20 +246,26 @@ export function OptionsMenu({
const activeCount = options.filter(o => o.checked && !o.disabled).length
return (
- 0 ? `${label} — ${activeCount} on` : label}
- aria-label={label}
- className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'relative h-8 w-8')}
- >
-
- {activeCount > 0 && (
- // A small presence dot (#1046): that some options are on is the signal; the exact count
- // is one click away in the menu, so the number was noise.
-
- )}
-
+
+
+ }
+ >
+
+ {activeCount > 0 && (
+ // A small presence dot (#1046): that some options are on is the signal; the exact count
+ // is one click away in the menu, so the number was noise.
+
+ )}
+
+ {activeCount > 0 ? `${label} — ${activeCount} on` : label}
+
{runTarget && (
<>
diff --git a/packages/framework-dashboard/components/PresetsMenu.tsx b/packages/framework-dashboard/components/PresetsMenu.tsx
index f3b020a1..e9a842b4 100644
--- a/packages/framework-dashboard/components/PresetsMenu.tsx
+++ b/packages/framework-dashboard/components/PresetsMenu.tsx
@@ -3,6 +3,7 @@ import { SquareSlash, Plus, X } from 'lucide-react'
import { cn } from '../lib/utils.js'
import { buttonVariants } from './ui/button.js'
import { OptionLabel } from './ui/option-label.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -43,20 +44,26 @@ function SavedPresetRow({
return (
onLoad(preset.prompt, preset.label)} className="items-center gap-2">
{preset.label}
- {
- // Delete, not load — keep the row's own click out of it.
- e.stopPropagation()
- onDelete(preset.id)
- }}
- title={`Delete "${preset.label}"`}
- aria-label={`Delete preset ${preset.label}`}
- className="rounded p-0.5 text-[var(--color-muted-foreground)] hover:text-danger"
- >
-
-
+
+ {
+ // Delete, not load — keep the row's own click out of it.
+ e.stopPropagation()
+ onDelete(preset.id)
+ }}
+ aria-label={`Delete preset ${preset.label}`}
+ className="rounded p-0.5 text-[var(--color-muted-foreground)] hover:text-danger"
+ />
+ }
+ >
+
+
+ Delete "{preset.label}"
+
)
}
@@ -88,29 +95,44 @@ export function PresetsMenu({
}) {
return (
-
-
-
+
+
+ }
+ >
+
+
+ Load a preset prompt — also available by typing / in the editor
+
Presets
- {presets.map(p => (
- onLoad(p.render(), p.label, p.newSession)}
- {...(p.tooltip ? { title: p.tooltip } : {})}
- className="items-start"
- >
-
-
- ))}
+ {presets.map(p => {
+ const itemProps = {
+ disabled: busy,
+ onClick: () => onLoad(p.render(), p.label, p.newSession),
+ className: 'items-start',
+ }
+ const label =
+ if (!p.tooltip)
+ return (
+
+ {label}
+
+ )
+ return (
+
+ }>{label}
+ {p.tooltip}
+
+ )
+ })}
{customPresets.length > 0 && (
diff --git a/packages/framework-dashboard/components/PreviewBar.tsx b/packages/framework-dashboard/components/PreviewBar.tsx
index 0a3798cf..f1e67739 100644
--- a/packages/framework-dashboard/components/PreviewBar.tsx
+++ b/packages/framework-dashboard/components/PreviewBar.tsx
@@ -4,7 +4,7 @@ import type { ServeTarget } from '@gemstack/the-framework'
import { sendPreview, onServeTargets, sendStopPreview, onPreviewStatus } from '../server/control.telefunc.js'
import { useAction } from '../lib/use-action.js'
import { Button } from './ui/button.js'
-import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './ui/tooltip.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuGroup, DropdownMenuLabel } from './ui/dropdown-menu.js'
// On-demand app Serve (#475): a per-project button that serves the project's built result
@@ -72,7 +72,7 @@ export function PreviewBar({
// Open (the live URL ↗) joined to a Stop (⏹). When stopped, a plain Serve button, or — in a
// multi-package repo — a split Serve + caret picker over the servable apps.
const controls = (
-
+ <>
{url ? (
@@ -160,7 +160,7 @@ export function PreviewBar({
{busy ? 'Starting…' : 'Serve the built result'}
)}
-
+ >
)
// Inline: just the control, for the project action bar. Errors sit below on their own line.
diff --git a/packages/framework-dashboard/components/ProjectActions.tsx b/packages/framework-dashboard/components/ProjectActions.tsx
index be4cb20f..d2a5658d 100644
--- a/packages/framework-dashboard/components/ProjectActions.tsx
+++ b/packages/framework-dashboard/components/ProjectActions.tsx
@@ -1,6 +1,5 @@
import { WorkspaceActions } from './WorkspaceActions.js'
import { GitStatusBar } from './GitStatusBar.js'
-import { TooltipProvider } from './ui/tooltip.js'
// The project home's action bar (#488). Git status (#491) reads on the left, the actions group on
// the right. Both halves are shared with a session's bar (#809), so the two pages cannot drift:
@@ -8,11 +7,9 @@ import { TooltipProvider } from './ui/tooltip.js'
export function ProjectActions({ projectId }: { projectId: string }) {
return (
)
}
diff --git a/packages/framework-dashboard/components/ResolvedOptions.test.tsx b/packages/framework-dashboard/components/ResolvedOptions.test.tsx
index 07e114b8..02f7ca3c 100644
--- a/packages/framework-dashboard/components/ResolvedOptions.test.tsx
+++ b/packages/framework-dashboard/components/ResolvedOptions.test.tsx
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ResolvedOptions } from './ResolvedOptions.js'
import type { OptionRow } from './OptionsMenu.js'
+import { hoverTooltip, unhoverTooltip } from '../test-utils.js'
const rows = (over: Partial
> = {}): OptionRow[] => [
{ key: 'autopilot', label: 'Autopilot', title: 'a', description: 'a', checked: over.autopilot ?? false },
@@ -36,7 +37,7 @@ describe('ResolvedOptions (#842)', () => {
expect(screen.queryByText('Browser')).toBeNull()
})
- test('a value inherited from the repo yml is marked as not yours', () => {
+ test('a value inherited from the repo yml is marked as not yours', async () => {
render(
{
fileConfig={{}}
/>,
)
- const repo = screen.getByText('Technical control').closest('span')
- const yours = screen.getByText('Autopilot').closest('span')
- expect(repo?.textContent).toContain('repo')
- expect(repo?.getAttribute('title')).toContain('the-framework.yml')
- expect(yours?.textContent).not.toContain('repo')
- expect(yours?.getAttribute('title')).toContain('Your setting')
+ const repo = screen.getByText('Technical control')
+ const yours = screen.getByText('Autopilot')
+ expect(repo.textContent).toContain('repo')
+ // Which tier a chip came from is a hover away, on the chip itself.
+ expect((await hoverTooltip(repo)).textContent).toContain('the-framework.yml')
+ unhoverTooltip(repo)
+ expect(yours.textContent).not.toContain('repo')
+ expect((await hoverTooltip(yours)).textContent).toContain('Your setting')
})
test('shows the yml keys the gear cannot set, always as the repo tier', () => {
diff --git a/packages/framework-dashboard/components/ResolvedOptions.tsx b/packages/framework-dashboard/components/ResolvedOptions.tsx
index e730a077..c9395e5e 100644
--- a/packages/framework-dashboard/components/ResolvedOptions.tsx
+++ b/packages/framework-dashboard/components/ResolvedOptions.tsx
@@ -1,6 +1,7 @@
import type { FrameworkFileConfig, Preferences } from '@gemstack/the-framework'
import type { PreferenceSources } from '../lib/preferences.js'
import type { OptionRow } from './OptionsMenu.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
/**
* What this session will actually run with, inline under the launcher (#842).
@@ -32,22 +33,27 @@ export function ResolvedOptions({
Settings:
{chips.map(chip => (
-
+
+ }
+ >
+ {chip.label}
+ {chip.repo && repo }
+
+
+ {chip.repo
? 'From this repo’s the-framework.yml, committed for everyone who clones it'
- : 'Your setting, from the options gear'
- }
- className={
- chip.repo
- ? 'rounded border border-dashed border-border px-1.5 py-0.5'
- : 'rounded border border-transparent bg-muted px-1.5 py-0.5'
- }
- >
- {chip.label}
- {chip.repo && repo }
-
+ : 'Your setting, from the options gear'}
+
+
))}
)
diff --git a/packages/framework-dashboard/components/RoutineWork.test.tsx b/packages/framework-dashboard/components/RoutineWork.test.tsx
index 0de8d988..7173937c 100644
--- a/packages/framework-dashboard/components/RoutineWork.test.tsx
+++ b/packages/framework-dashboard/components/RoutineWork.test.tsx
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { AutoPmReport, Preferences, ProjectSummary } from '@gemstack/the-framework'
import { AUTO_PM_ROUTINES, AUTO_PM_DRAIN_JOB } from '@gemstack/the-framework/client'
+import { hoverTooltip } from '../test-utils.js'
// Everything the card reads goes through a lib module, so the mocks stop short of telefunc: an
// unmocked `*.telefunc.js` in the import graph fails as an assertIsNotBrowser bug report.
@@ -97,7 +98,8 @@ describe('RoutineWork (#1159)', () => {
expect(screen.getAllByRole('checkbox')).toHaveLength(1)
fireEvent.click(screen.getByText('Auto-run'))
expect(updatePreferences).toHaveBeenCalledWith({ autoPm: true })
- expect(screen.getByTitle('Automatically run this prompt on a regular schedule.')).toBeTruthy()
+ const label = screen.getByText('Auto-run').closest('label')!
+ expect((await hoverTooltip(label)).textContent).toBe('Automatically run this prompt on a regular schedule.')
})
test('several projects get a picker, and Run now honours it', async () => {
diff --git a/packages/framework-dashboard/components/RoutineWork.tsx b/packages/framework-dashboard/components/RoutineWork.tsx
index 901ff382..c87410c1 100644
--- a/packages/framework-dashboard/components/RoutineWork.tsx
+++ b/packages/framework-dashboard/components/RoutineWork.tsx
@@ -11,6 +11,7 @@ import { formatUntil } from '../lib/format-date.js'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
import { Button } from './ui/button.js'
import { Checkbox } from './ui/checkbox.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
// The Overview's "Routine work" card (#1159): the jobs the idle sweep fires on a schedule, each with
// a Run now button that starts it against a project immediately.
@@ -120,13 +121,13 @@ export function RoutineWork({ onSelectProject }: { onSelectProject: (id: string)
{/* The same `autoPm` preference the usage panel offers (#1161), which is the point: one
switch, shown where the schedule it governs is listed. */}
-
- updatePreferences({ autoPm: checked })} />
- {autoRunLabel}
-
+
+ }>
+ updatePreferences({ autoPm: checked })} />
+ {autoRunLabel}
+
+ Automatically run this prompt on a regular schedule.
+
Only while nothing else is running and the week's allowance is not already spent.
diff --git a/packages/framework-dashboard/components/RunHandoff.tsx b/packages/framework-dashboard/components/RunHandoff.tsx
index abd8e760..1e0efe3f 100644
--- a/packages/framework-dashboard/components/RunHandoff.tsx
+++ b/packages/framework-dashboard/components/RunHandoff.tsx
@@ -7,6 +7,7 @@ import { cn } from '../lib/utils.js'
import { DiffStat } from './DiffView.js'
import { Button } from './ui/button.js'
import { Checkbox } from './ui/checkbox.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
// The end-of-session handoff (#799): what this session produced, and the next step offered rather
// than described. Before this, a finished session showed no branch, no commits and no diff, so
@@ -132,10 +133,13 @@ function Arm({
onChange: (checked: boolean) => void
}) {
return (
-
- onChange(next === true)} />
- {label}
-
+
+ }>
+ onChange(next === true)} />
+ {label}
+
+ {title}
+
)
}
diff --git a/packages/framework-dashboard/components/RunHistory.tsx b/packages/framework-dashboard/components/RunHistory.tsx
index 667cceb8..20793d3b 100644
--- a/packages/framework-dashboard/components/RunHistory.tsx
+++ b/packages/framework-dashboard/components/RunHistory.tsx
@@ -11,6 +11,7 @@ import { runLabel } from '../lib/run-label.js'
import { AgentLogo } from './agent-logos.js'
import { AddProjectPanel } from './AddProjectPanel.js'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from './ui/dropdown-menu.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import {
Sidebar,
SidebarContent,
@@ -247,9 +248,12 @@ export function RunHistory({
-
-
-
+
+ }>
+
+
+ Settings
+
@@ -274,12 +278,18 @@ function OverviewButton({ active, count, onClick }: { active: boolean; count: nu
Overview
{count > 0 && (
-
- {count}
-
+
+
+ }
+ >
+ {count}
+
+
+ {count} item{count === 1 ? '' : 's'} in your Human Queue
+
+
)}
)
@@ -332,11 +342,17 @@ function ProjectsNav({
)}
>
{/* The activated dot the picker used, kept so the two project lists still read alike. */}
-
+
+
+ }
+ />
+ {p.activated ? 'activated' : 'not activated'}
+
{p.activated ? 'Activated' : 'Not activated'}:
{p.name}
@@ -402,10 +418,13 @@ function NewButton({
if (projects.length === 0) {
return (
<>
- setAdding(true)} title="Add a project to start a session">
-
- New session
-
+
+ setAdding(true)} />}>
+
+ New session
+
+ Add a project to start a session
+
{adding && onProjectAdded?.()} onClose={() => setAdding(false)} />}
>
)
@@ -504,11 +523,23 @@ function RunRow({
{(remote || agent) && (
{remote && (
-
-
-
+
+ }>
+
+
+ {remoteLabel ? `Runs on ${remoteLabel}` : 'Runs on a connected device'}
+
+ )}
+ {agent && (
+ // The logo is the only thing naming the agent on this row, so the trigger carries the
+ // accessible name and the logo itself stays decorative.
+
+ }>
+
+
+ {AGENT_LABELS[agent]}
+
)}
- {agent && }
)}
diff --git a/packages/framework-dashboard/components/RunOutcomes.tsx b/packages/framework-dashboard/components/RunOutcomes.tsx
index d8a976e5..d38ad8ec 100644
--- a/packages/framework-dashboard/components/RunOutcomes.tsx
+++ b/packages/framework-dashboard/components/RunOutcomes.tsx
@@ -19,13 +19,10 @@ export function RunOutcomes({ counts }: { counts: Record }) {
return (
+ {/* No `title`: the legend below already names every segment and count, and a system
+ tooltip is the one slow tooltip left in a dashboard of instant ones (#1149). */}
{OUTCOMES.filter(o => counts[o.key] > 0).map(o => (
-
+
))}
diff --git a/packages/framework-dashboard/components/SessionActionsMenu.tsx b/packages/framework-dashboard/components/SessionActionsMenu.tsx
index 86865fd3..89b51f13 100644
--- a/packages/framework-dashboard/components/SessionActionsMenu.tsx
+++ b/packages/framework-dashboard/components/SessionActionsMenu.tsx
@@ -24,6 +24,7 @@ import { cn } from '../lib/utils.js'
import { buttonVariants } from './ui/button.js'
import { OptionLabel } from './ui/option-label.js'
import { ConfirmDialog } from './ui/confirm-dialog.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -127,14 +128,20 @@ export function SessionActionsMenu({
return (
<>
-
-
-
+
+
+ }
+ >
+
+
+ Session actions
+
{githubUrl && (
}>
diff --git a/packages/framework-dashboard/components/ThemeToggle.test.tsx b/packages/framework-dashboard/components/ThemeToggle.test.tsx
index ac71bd31..7292f7d3 100644
--- a/packages/framework-dashboard/components/ThemeToggle.test.tsx
+++ b/packages/framework-dashboard/components/ThemeToggle.test.tsx
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { hoverTooltip } from '../test-utils.js'
const updatePreferences = vi.hoisted(() => vi.fn())
const usePreferences = vi.hoisted(() => vi.fn())
@@ -32,15 +33,17 @@ describe('ThemeToggle (#754)', () => {
expect(updatePreferences).toHaveBeenCalledWith({ theme: 'dark' })
})
- test('the trigger shows the current theme, so the header says which is on', () => {
+ test('the trigger shows the current theme, so the header says which is on', async () => {
usePreferences.mockReturnValue({ theme: 'dark' })
render( )
- expect(screen.getByRole('button', { name: /theme/i }).getAttribute('title')).toBe('Theme: Dark')
+ const tip = await hoverTooltip(screen.getByRole('button', { name: /theme/i }))
+ expect(tip.textContent).toBe('Theme: Dark')
})
- test('an unset preference reads as system, the default', () => {
+ test('an unset preference reads as system, the default', async () => {
usePreferences.mockReturnValue({})
render( )
- expect(screen.getByRole('button', { name: /theme/i }).getAttribute('title')).toBe('Theme: System')
+ const tip = await hoverTooltip(screen.getByRole('button', { name: /theme/i }))
+ expect(tip.textContent).toBe('Theme: System')
})
})
diff --git a/packages/framework-dashboard/components/ThemeToggle.tsx b/packages/framework-dashboard/components/ThemeToggle.tsx
index d8bb3eb0..5e752751 100644
--- a/packages/framework-dashboard/components/ThemeToggle.tsx
+++ b/packages/framework-dashboard/components/ThemeToggle.tsx
@@ -3,6 +3,7 @@ import { usePreferences, updatePreferences, themePreference, type ThemePreferenc
import { cn } from '../lib/utils.js'
import { buttonVariants } from './ui/button.js'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from './ui/dropdown-menu.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
// The appearance control (#754). The theme has been switchable since #725, but it lived inside the
// per-run options gear: an app-wide setting filed under one run's options, and absent entirely on a
@@ -27,13 +28,19 @@ export function ThemeToggle() {
return (
-
-
-
+
+
+ }
+ >
+
+
+ Theme: {current.label}
+
{THEME_OPTIONS.map(t => {
const Icon = t.icon
@@ -43,7 +50,6 @@ export function ThemeToggle() {
// Keep the menu open so the theme visibly changes underneath the pick.
closeOnClick={false}
onClick={() => updatePreferences({ theme: t.value })}
- title={`${t.label} theme`}
>
diff --git a/packages/framework-dashboard/components/TicketsPanel.tsx b/packages/framework-dashboard/components/TicketsPanel.tsx
index d6f49d61..911b27a6 100644
--- a/packages/framework-dashboard/components/TicketsPanel.tsx
+++ b/packages/framework-dashboard/components/TicketsPanel.tsx
@@ -8,6 +8,7 @@ import { Badge } from './ui/badge.js'
import { useAction } from '../lib/use-action.js'
import { cn } from '../lib/utils.js'
import { ScrollArea } from './ui/scroll-area.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
/**
* The prompt behind "Import tickets from GitHub" (#697), read from the preset rather than written
@@ -99,28 +100,34 @@ export function TicketsPanel({
{ticket.title}
-
+ void queue(ticket)}
+ />
+ }
+ >
+ {queued.has(ticket.file) ? (
+ <>
+ Queued
+ >
+ ) : (
+ <>
+ Queue
+ >
+ )}
+
+
+ {queued.has(ticket.file)
? 'Already added to the queue'
- : 'Add to Queue (TODO_AGENTS.md), for the next session to work on'
- }
- onClick={() => void queue(ticket)}
- >
- {queued.has(ticket.file) ? (
- <>
- Queued
- >
- ) : (
- <>
- Queue
- >
- )}
-
+ : 'Add to Queue (TODO_AGENTS.md), for the next session to work on'}
+
+
{ticket.summary &&
{ticket.summary}
}
diff --git a/packages/framework-dashboard/components/WorkingNow.test.tsx b/packages/framework-dashboard/components/WorkingNow.test.tsx
deleted file mode 100644
index 763658f5..00000000
--- a/packages/framework-dashboard/components/WorkingNow.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { afterEach, describe, expect, test, vi } from 'vitest'
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import type { ActiveRun } from '@gemstack/the-framework'
-import { WorkingNow } from './WorkingNow.js'
-
-afterEach(cleanup)
-
-const run = (over: Partial
= {}): ActiveRun =>
- ({
- projectId: 'p1',
- projectName: 'my-app',
- runId: 'run-9',
- cwd: '/tmp/p1',
- status: 'running',
- intent: 'add a login page',
- ...over,
- }) as ActiveRun
-
-describe('Working now', () => {
- test('a row opens the session it describes, not its project home', () => {
- const onSelectRun = vi.fn()
- render( )
- fireEvent.click(screen.getByText('add a login page'))
- // The row carries the run id and used to drop it, landing on the launcher: an empty composer
- // with no sign of the session the row had just named.
- expect(onSelectRun).toHaveBeenCalledWith('p1', 'run-9')
- })
-
- test('each row opens its own session when a project has several in flight', () => {
- const onSelectRun = vi.fn()
- render(
- ,
- )
- fireEvent.click(screen.getByText('second'))
- expect(onSelectRun).toHaveBeenCalledWith('p1', 'run-2')
- })
-
- test('nothing running says so and offers no rows', () => {
- render( )
- expect(screen.getByText('Nothing running.')).toBeTruthy()
- expect(screen.queryByRole('button')).toBeNull()
- })
-})
diff --git a/packages/framework-dashboard/components/WorkingNow.tsx b/packages/framework-dashboard/components/WorkingNow.tsx
deleted file mode 100644
index fa8d48ae..00000000
--- a/packages/framework-dashboard/components/WorkingNow.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { ActiveRun } from '@gemstack/the-framework'
-import { cn } from '../lib/utils.js'
-
-// Every row here is one session, and it says so: a status dot, the project, the session name and
-// what it was asked to build. It used to open that project's launcher, dropping the run id it was
-// holding, so pressing "Building: my-app, add a login page" landed on an empty composer with no
-// sign of the session just described. The row goes where it points.
-export function WorkingNow({
- active,
- onSelectRun,
-}: {
- active: ActiveRun[]
- onSelectRun: (id: string, runId: string) => void
-}) {
- if (active.length === 0) {
- return Nothing running.
- }
- return (
-
- {active.map(run => (
- // Keyed by project + run (#738): one project can have several runs in flight.
-
- onSelectRun(run.projectId, run.runId)}
- className="flex w-full flex-col items-start gap-0.5 py-2 text-left hover:opacity-80"
- >
-
- {/* Decorative dot + sr-only status (#695/U33): color alone reaches no screen reader. */}
-
- {run.readyForMerge ? 'Ready for merge' : 'Building'}:
- {run.projectName}
- {run.sessionName && {run.sessionName} }
-
- {(run.intent || run.scope) && (
- {run.intent || run.scope}
- )}
-
-
- ))}
-
- )
-}
diff --git a/packages/framework-dashboard/components/WorkspaceActions.test.tsx b/packages/framework-dashboard/components/WorkspaceActions.test.tsx
index 7b9afd74..cc4f041e 100644
--- a/packages/framework-dashboard/components/WorkspaceActions.test.tsx
+++ b/packages/framework-dashboard/components/WorkspaceActions.test.tsx
@@ -26,14 +26,10 @@ vi.mock('../lib/preferences.js', () => ({ usePreferences: () => prefs, updatePre
vi.mock('../lib/editors.js', () => ({ useDetectedEditors: () => detectedEditors }))
const { WorkspaceActions } = await import('./WorkspaceActions.js')
-const { TooltipProvider } = await import('./ui/tooltip.js')
-const renderActions = (runId?: string) =>
- render(
-
-
- ,
- )
+// No TooltipProvider: every tooltip carries its own (zero) delay since #1149, so the component
+// renders standalone exactly as the bars render it.
+const renderActions = (runId?: string) => render( )
// The folder and editor buttons are icon-only; find them by their position among the buttons
// (GitHub is an anchor, so the buttons are: folder, editor, serve).
diff --git a/packages/framework-dashboard/components/WorkspaceActions.tsx b/packages/framework-dashboard/components/WorkspaceActions.tsx
index 9f9ba985..fa178586 100644
--- a/packages/framework-dashboard/components/WorkspaceActions.tsx
+++ b/packages/framework-dashboard/components/WorkspaceActions.tsx
@@ -27,8 +27,6 @@ import {
// session and acts on the project's tree, a session passes its id and every action addresses its
// own worktree instead. Opening a session in your editor is the whole point of a worktree, and
// that was the one page where you could not.
-//
-// Must be rendered inside a TooltipProvider; the bars that use it own the delay.
export function WorkspaceActions({
projectId,
runId,
@@ -88,15 +86,21 @@ export function WorkspaceActions({
{runId ? "Open this session's folder" : 'Open folder'} (Finder / Explorer)
-
-
-
+
+
+ }
+ >
+
+
+ Open in editor
+
void open('editor')}>
@@ -109,7 +113,6 @@ export function WorkspaceActions({
disabled={busy}
closeOnClick={false}
onClick={() => updatePreferences({ editor: '' })}
- title="Use $FRAMEWORK_EDITOR, or VS Code"
className="items-start"
>
@@ -121,7 +124,6 @@ export function WorkspaceActions({
disabled={busy}
closeOnClick={false}
onClick={() => updatePreferences({ editor: e.bin })}
- title={`Open in ${e.label} (${e.bin})`}
className="items-start"
>
diff --git a/packages/framework-dashboard/components/ui/copy-button.tsx b/packages/framework-dashboard/components/ui/copy-button.tsx
index bef5386e..970f17a7 100644
--- a/packages/framework-dashboard/components/ui/copy-button.tsx
+++ b/packages/framework-dashboard/components/ui/copy-button.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { cn } from '../../lib/utils.js'
+import { Tooltip, TooltipTrigger, TooltipContent } from './tooltip.js'
// A small copy-to-clipboard affordance (#948) for the strings users take to a terminal:
// branch names, session ids, URLs. Flashes a check for a beat so the click visibly landed.
@@ -19,18 +20,24 @@ export function CopyButton({ text, label, className }: { text: string; label: st
const Icon = copied ? Check : Copy
return (
-
-
-
+
+
+ }
+ >
+
+
+ {copied ? 'Copied' : label}
+
)
}
diff --git a/packages/framework-dashboard/components/ui/tooltip.tsx b/packages/framework-dashboard/components/ui/tooltip.tsx
index 3e015555..26f5e05f 100644
--- a/packages/framework-dashboard/components/ui/tooltip.tsx
+++ b/packages/framework-dashboard/components/ui/tooltip.tsx
@@ -4,11 +4,17 @@ import { Tooltip as TooltipPrimitive } from '@base-ui-components/react/tooltip'
import { cn } from '../../lib/utils.js'
// A trimmed shadcn-style Tooltip on Base UI (already a dep via animate-ui — no Radix pulled
-// in). Wrap a group in (shared open/close delay), then per item:
+// in). Per item:
// }>…Label
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
-const TooltipTrigger = TooltipPrimitive.Trigger
+
+// Tooltips open instantly (#1149). Base UI reads the delay off the trigger, and an explicit
+// trigger delay beats any , so this one default governs every tooltip; no
+// provider is needed anywhere.
+function TooltipTrigger(props: ComponentProps) {
+ return
+}
function TooltipContent({
className,
@@ -30,6 +36,11 @@ function TooltipContent({
{...(align ? { align } : {})}
>
{
test('formats a real timestamp', () => {
@@ -26,6 +26,39 @@ describe('format-date (#759)', () => {
})
})
+describe('formatAge (#1139)', () => {
+ const NOW = new Date('2026-07-25T12:00:00.000Z')
+ const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString()
+ const S = 1000
+ const M = 60 * S
+ const H = 60 * M
+ const D = 24 * H
+
+ test('names seconds, minutes, hours, days, weeks and years, floored', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(NOW)
+ try {
+ expect(formatAge(ago(22 * S))).toBe('22s ago')
+ expect(formatAge(ago(0))).toBe('0s ago')
+ // 90s is a minute and a half — floored to one minute, not rounded up to two.
+ expect(formatAge(ago(90 * S))).toBe('1m ago')
+ expect(formatAge(ago(30 * M))).toBe('30m ago')
+ expect(formatAge(ago(3 * H))).toBe('3h ago')
+ expect(formatAge(ago(5 * D))).toBe('5d ago')
+ expect(formatAge(ago(14 * D))).toBe('2w ago')
+ expect(formatAge(ago(400 * D))).toBe('1y ago')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test('an absent or unparseable timestamp reads as the fallback', () => {
+ expect(formatAge(undefined)).toBe('—')
+ expect(formatAge('not a date')).toBe('—')
+ expect(formatAge(undefined, 'never')).toBe('never')
+ })
+})
+
describe('formatUntil (#1161/#1159)', () => {
test('counts down in minutes, then in hours', () => {
expect(formatUntil(Date.now() + 4 * 60_000)).toBe('in 4 min')
diff --git a/packages/framework-dashboard/lib/format-date.ts b/packages/framework-dashboard/lib/format-date.ts
index 8ee1e8c2..03cd966b 100644
--- a/packages/framework-dashboard/lib/format-date.ts
+++ b/packages/framework-dashboard/lib/format-date.ts
@@ -35,6 +35,32 @@ export function formatDate(value: string | undefined, fallback = '—'): string
return date ? date.toLocaleDateString() : fallback
}
+/**
+ * A compact age (#1139): "22s ago" / "30m ago" / "5d ago" / "2w ago", the units the Agents list
+ * dates its rows in. Finer than {@link formatRelative} at both ends — it names seconds (a session
+ * that ended moments ago) and never falls back to a bare date (weeks, then years, stay relative) —
+ * so a row reads the same whether it finished this minute or last month. Pair it with
+ * {@link formatDateTime} in a `title` for the exact moment.
+ *
+ * Floored, not rounded: "1m ago" should mean at least a minute has passed, not "closer to a minute
+ * than to two".
+ */
+export function formatAge(value: string | undefined, fallback = '—'): string {
+ const date = parse(value)
+ if (!date) return fallback
+ const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000))
+ if (seconds < 60) return `${seconds}s ago`
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes}m ago`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ago`
+ const days = Math.floor(hours / 24)
+ if (days < 7) return `${days}d ago`
+ const weeks = Math.floor(days / 7)
+ if (weeks < 52) return `${weeks}w ago`
+ return `${Math.floor(days / 365)}y ago`
+}
+
/**
* How long until `at` (epoch ms), as "in 4 min" / "in 1 hr". Past due reads as "any moment": a
* schedule the daemon has not reached yet is imminent, not late. Shared by the usage panel's
diff --git a/packages/framework-dashboard/lib/run-option-rows.ts b/packages/framework-dashboard/lib/run-option-rows.ts
index 04b6e788..f8fd0249 100644
--- a/packages/framework-dashboard/lib/run-option-rows.ts
+++ b/packages/framework-dashboard/lib/run-option-rows.ts
@@ -14,14 +14,16 @@ import { AGENTS, AGENT_LABELS, autopilotEnabled, handoffFromPreferences, type Ag
export type OptionRow = {
key: keyof Preferences
label: string
+ /** The long form, shown in the row's tooltip. */
title: string
/** A short one-line summary shown under the label (#654). */
description?: string
checked: boolean
/** Disabled beyond the form-wide busy flag (e.g. Eco has nothing to trim under Vanilla). */
disabled?: boolean
- /** Why it's disabled, shown in the description so a greyed row isn't a mystery (the `title`
- * tooltip is suppressed on disabled dropdown items). Only rendered while {@link disabled}. */
+ /** Why it's disabled, shown in the description so a greyed row isn't a mystery (a disabled
+ * dropdown item takes no pointer events, so its tooltip never opens). Only rendered while
+ * {@link disabled}. */
disabledReason?: string
}
diff --git a/packages/framework-dashboard/pages/index/+Page.tsx b/packages/framework-dashboard/pages/index/+Page.tsx
index 8aa89940..ac99087c 100644
--- a/packages/framework-dashboard/pages/index/+Page.tsx
+++ b/packages/framework-dashboard/pages/index/+Page.tsx
@@ -180,9 +180,10 @@ export default function Page() {
go({ projectId: id, runId: null }) // switching projects always returns to the home launcher
}
- // Naming a session in another project. The Overview's rows know which run they are about, and
- // going through selectProject drops that on the way, landing on the launcher instead of the
- // session the row was describing.
+ // Naming a session in another project. The Overview's cross-project rows — the sidebar recents,
+ // the Agents view (#1139), and the hot tickets — know which run they are about, and going through
+ // selectProject drops that on the way, landing on the launcher instead of the session the row was
+ // describing.
const selectRunInProject = (id: string, runId: string) => {
setAdopting(false)
go({ projectId: id, runId })
@@ -353,10 +354,7 @@ export default function Page() {
selectedRunId={runId}
onSelect={selectRun}
recentRuns={recentRuns}
- onSelectRecent={(pid, rid) => {
- setAdopting(false)
- go({ projectId: pid, runId: rid })
- }}
+ onSelectRecent={selectRunInProject}
projects={projects}
onNewSessionInProject={newSessionInProject}
onProjectAdded={() => {
diff --git a/packages/framework-dashboard/test-utils.ts b/packages/framework-dashboard/test-utils.ts
new file mode 100644
index 00000000..9f6e173e
--- /dev/null
+++ b/packages/framework-dashboard/test-utils.ts
@@ -0,0 +1,19 @@
+import { fireEvent, screen } from '@testing-library/react'
+
+/**
+ * Hover a tooltip trigger and hand back the tooltip it opens (#1149).
+ *
+ * Every hint in the dashboard is the custom tooltip now, so a test that used to read a `title`
+ * attribute off an element has to hover like a user instead: Base UI opens on `mouseenter` +
+ * a move, and portals the popup out of the trigger's subtree.
+ */
+export async function hoverTooltip(trigger: Element): Promise {
+ fireEvent.mouseEnter(trigger)
+ fireEvent.mouseMove(trigger)
+ return await screen.findByRole('tooltip')
+}
+
+/** Move off a trigger, so the next {@link hoverTooltip} is the only tooltip open. */
+export function unhoverTooltip(trigger: Element): void {
+ fireEvent.mouseLeave(trigger)
+}
diff --git a/packages/the-framework/src/dashboard/dashboard.test.ts b/packages/the-framework/src/dashboard/dashboard.test.ts
index 4ebd69a1..3c0d896b 100644
--- a/packages/the-framework/src/dashboard/dashboard.test.ts
+++ b/packages/the-framework/src/dashboard/dashboard.test.ts
@@ -44,6 +44,17 @@ test('buildDashboard rolls up totals, run-status, and per-project counts', async
assert.equal(a.openTodos, 2)
assert.equal(a.running, true)
assert.equal(data.projects.find(p => p.projectId === 'b')!.running, false)
+
+ // Finished sessions feed the Agents view's "Recent" column, newest-finished first (#1139); the
+ // live running run is not one of them.
+ assert.deepEqual(
+ data.recentAgents.map(r => ({ p: r.projectName, id: r.run.id })),
+ [
+ { p: 'a', id: '2026-07-14T09:00:00Z' },
+ { p: 'a', id: '2026-07-13T09:00:00Z' },
+ { p: 'b', id: '2026-07-12T09:00:00Z' },
+ ],
+ )
})
test('buildDashboard buckets run activity across a 14-day window, oldest-first', async () => {
diff --git a/packages/the-framework/src/dashboard/dashboard.ts b/packages/the-framework/src/dashboard/dashboard.ts
index 3cb497de..a540cf21 100644
--- a/packages/the-framework/src/dashboard/dashboard.ts
+++ b/packages/the-framework/src/dashboard/dashboard.ts
@@ -2,7 +2,7 @@ import { listRuns, type RunMeta, type RunStatus } from '../store/index.js'
import type { ProjectSummary } from './projects.js'
import { collectQueue, type ProjectQueue } from './queue.js'
import { hasTickets } from './tickets.js'
-import { buildOverview, type ActiveRun, type RecentProject, type OverviewDeps } from './overview.js'
+import { buildOverview, type ActiveRun, type RecentProject, type RecentRun, type OverviewDeps } from './overview.js'
// The Overview dashboard page (#471): the cross-project rollup that used to live cramped in
// the first sidebar, promoted to a real at-a-glance page. It reuses buildOverview for the
@@ -49,6 +49,11 @@ export interface DashboardData {
activity: ActivityDay[]
/** Runs going right now, most-recently-updated first (from {@link buildOverview}). */
active: ActiveRun[]
+ /**
+ * Finished sessions across every project, most-recently-finished first (capped), for the Agents
+ * view's "Recent" column (#1139). The "Current" column reads {@link active}.
+ */
+ recentAgents: RecentRun[]
/** The most recently active projects (capped). */
recent: RecentProject[]
/** Every registered project with its run/TODO rollup, most-recently-active first. */
@@ -60,6 +65,9 @@ export interface DashboardData {
/** How many days of run activity the chart covers. */
const ACTIVITY_DAYS = 14
+/** How many finished sessions the Agents view's "Recent" column pools across every project (#1139). */
+const RECENT_AGENTS_LIMIT = 20
+
/** Injectable readers/clock so {@link buildDashboard} is unit-testable off disk. */
export interface DashboardDeps extends OverviewDeps {
/** Archived runs for a project path. Defaults to {@link listRuns} (forgiving of a missing dir). */
@@ -107,6 +115,9 @@ export async function buildDashboard(projects: ProjectSummary[], deps: Dashboard
const runsByStatus: Record = { running: 0, done: 0, stopped: 0, failed: 0 }
const projectStats: ProjectStat[] = []
+ // Finished sessions across every project, for the Agents view's "Recent" column (#1139). Read
+ // off the same archived-runs pass that counts them, so it costs no extra disk.
+ const recentAgents: RecentRun[] = []
let totalRuns = 0
for (const project of projects) {
const runs = await listRunsFor(project.path)
@@ -115,6 +126,9 @@ export async function buildDashboard(projects: ProjectSummary[], deps: Dashboard
runsByStatus[run.status] += 1
const key = localDateKey(new Date(run.startedAt))
if (buckets.has(key)) buckets.set(key, buckets.get(key)! + 1)
+ // Archived runs are finished by definition; the guard is belt-and-braces against a
+ // just-self-healed row (#716) still reading as running.
+ if (run.status !== 'running') recentAgents.push({ projectId: project.id, projectName: project.name, run })
}
projectStats.push({
projectId: project.id,
@@ -128,6 +142,8 @@ export async function buildDashboard(projects: ProjectSummary[], deps: Dashboard
})
}
projectStats.sort((a, b) => (b.lastActivityAt ?? '').localeCompare(a.lastActivityAt ?? ''))
+ // Newest-finished first: a finished run's `updatedAt` is its last event, i.e. when it ended.
+ recentAgents.sort((a, b) => (b.run.updatedAt ?? '').localeCompare(a.run.updatedAt ?? ''))
return {
totals: {
@@ -139,6 +155,7 @@ export async function buildDashboard(projects: ProjectSummary[], deps: Dashboard
runsByStatus,
activity: dayKeys.map(date => ({ date, count: buckets.get(date) ?? 0 })),
active: overview.active,
+ recentAgents: recentAgents.slice(0, RECENT_AGENTS_LIMIT),
recent: overview.recent,
projects: projectStats,
queue,
diff --git a/packages/the-framework/src/dashboard/overview.test.ts b/packages/the-framework/src/dashboard/overview.test.ts
index 51bb219c..1672dc99 100644
--- a/packages/the-framework/src/dashboard/overview.test.ts
+++ b/packages/the-framework/src/dashboard/overview.test.ts
@@ -102,31 +102,41 @@ const ticket = (file: string, over: Partial = {}): WorkspaceTic
...over,
})
-test('ticketBucket: planned/spiked is in-progress, high priority is next, else queued', () => {
+test('ticketBucket: in-progress > ai-queue > high-priority, else null (#1139)', () => {
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')
+ assert.equal(ticketBucket(ticket('c', { priority: 'high' })), 'high-priority')
+ assert.equal(ticketBucket(ticket('d', { priority: 'p1' })), 'high-priority')
+ // Not in any of the three shown lanes: dropped from the card.
+ assert.equal(ticketBucket(ticket('e')), null)
+ assert.equal(ticketBucket(ticket('f', { priority: 'low' })), null)
+ // A queued ticket lands in the AI Queue, and that outranks a bare priority flag.
+ assert.equal(ticketBucket(ticket('g'), { queued: true }), 'ai-queue')
+ assert.equal(ticketBucket(ticket('h', { priority: 'high' }), { queued: true }), 'ai-queue')
+ // Work already under way outranks both: a planned (or queued-and-planned) ticket stays in-progress.
+ assert.equal(ticketBucket(ticket('i', { planned: true, priority: 'high' })), 'in-progress')
+ assert.equal(ticketBucket(ticket('j', { planned: true }), { queued: true }), 'in-progress')
})
-test('buildHotTickets pools every project, buckets each, and orders lane-first', async () => {
+test('buildHotTickets pools every project, buckets each, drops the rest, and orders lane-first', async () => {
const tickets: Record = {
'/a': [ticket('a1.md', { planned: true }), ticket('a2.md', { priority: 'high' })],
- '/b': [ticket('b1.md')],
+ '/b': [ticket('b1.md'), ticket('b2.md')],
}
const hot = await buildHotTickets([project('alpha', '/a'), project('beta', '/b')], {
tickets: async cwd => tickets[cwd] ?? [],
+ // beta's b1 is linked from its TODO_AGENTS.md, so it lands in the AI-Queue lane; b2 is in no
+ // lane and drops off the card entirely.
+ queue: async () => [
+ { projectId: 'beta', projectName: 'beta', open: 1, total: 1, items: [{ text: '[b one](tickets/b1.md) — a note', done: false }] },
+ ],
})
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' },
+ { p: 'beta', f: 'b1.md', b: 'ai-queue' },
+ { p: 'alpha', f: 'a2.md', b: 'high-priority' },
],
)
})
@@ -137,6 +147,7 @@ test('buildHotTickets tolerates a project whose tickets cannot be read', async (
if (cwd === '/bad') throw new Error('unreadable')
return [ticket('x.md', { priority: 'high' })]
},
+ queue: async () => [],
})
assert.deepEqual(hot.map(h => h.ticket.file), ['x.md'])
})
@@ -146,50 +157,51 @@ const runOn = (id: string, ticket: string, status: RunMeta['status'] = 'running'
({ version: 1, status, id, startedAt: 't', updatedAt: 't', passes: 0, ticket, cwd: '/w' }) as never
test('ticketBucket: a run implementing it is in-progress, whatever the plan/spike says (#1117)', () => {
- // The whole point of the link: a ticket nobody has planned yet, being coded right now, used to
- // sit in `queued` because the only evidence the lane had was a plan/spike file.
- assert.equal(ticketBucket(ticket('a'), true), 'in-progress')
- assert.equal(ticketBucket(ticket('b', { priority: 'high' }), true), 'in-progress')
+ // The whole point of the link: a ticket nobody has planned yet, being coded right now, would
+ // otherwise fall through to a non-shown lane on the strength of a plan/spike file alone.
+ assert.equal(ticketBucket(ticket('a'), { implementing: true }), 'in-progress')
+ assert.equal(ticketBucket(ticket('b', { priority: 'high' }), { implementing: true }), 'in-progress')
// Absent evidence, the #1112 inference is untouched.
- assert.equal(ticketBucket(ticket('c', { planned: true }), false), 'in-progress')
- assert.equal(ticketBucket(ticket('d'), false), 'queued')
+ assert.equal(ticketBucket(ticket('c', { planned: true }), { implementing: false }), 'in-progress')
+ assert.equal(ticketBucket(ticket('d'), { implementing: false }), null)
})
test('buildHotTickets marks the ticket a live run is implementing, with its run id (#1117)', async () => {
const hot = await buildHotTickets([project('alpha', '/a')], {
tickets: async () => [ticket('2026-07-25_login.md'), ticket('2026-07-26_other.md')],
liveRuns: async () => [runOn('run-7', 'tickets/2026-07-25_login.md')],
+ queue: async () => [],
})
const login = hot.find(h => h.ticket.file === '2026-07-25_login.md')
assert.equal(login?.bucket, 'in-progress', 'the ticket being coded is in progress')
assert.equal(login?.runId, 'run-7', 'and carries the run, so the card can link into it')
- // The ticket nobody is on keeps its own lane and gains nothing.
+ // The ticket nobody is on and nothing queues is in no lane, so it drops off the card (#1139).
const other = hot.find(h => h.ticket.file === '2026-07-26_other.md')
- assert.equal(other?.bucket, 'queued')
- assert.equal(other?.runId, undefined)
+ assert.equal(other, undefined)
})
test('buildHotTickets ignores a finished run and another project\'s ticket (#1117)', async () => {
- // A run that has ended is not implementing anything, however recently it stopped.
+ // A run that has ended is not implementing anything, however recently it stopped — so x.md carries
+ // no runId and sits in the AI Queue by its link alone.
const finished = await buildHotTickets([project('alpha', '/a')], {
tickets: async () => [ticket('x.md')],
liveRuns: async () => [runOn('run-7', 'tickets/x.md', 'done')],
+ queue: async () => [{ projectId: 'alpha', projectName: 'alpha', open: 1, total: 1, items: [{ text: '[x](tickets/x.md)', done: false }] }],
})
assert.equal(finished[0]?.runId, undefined)
- assert.equal(finished[0]?.bucket, 'queued')
+ assert.equal(finished[0]?.bucket, 'ai-queue')
// A ticket path is only unique inside its own repo, so beta's run must not light up alpha's
- // identically-named ticket.
+ // identically-named ticket. alpha's x.md is in no lane (no run, no queue link, no priority), so
+ // only beta's implementing copy survives.
const twoProjects = await buildHotTickets([project('alpha', '/a'), project('beta', '/b')], {
tickets: async () => [ticket('x.md')],
liveRuns: async cwd => (cwd === '/b' ? [runOn('run-9', 'tickets/x.md')] : []),
+ queue: async () => [],
})
assert.deepEqual(
twoProjects.map(h => ({ p: h.projectName, run: h.runId })),
- [
- { p: 'beta', run: 'run-9' },
- { p: 'alpha', run: undefined },
- ],
- 'only beta reads as implementing, and it sorts into the in-progress lane first',
+ [{ p: 'beta', run: 'run-9' }],
+ 'only beta reads as implementing; alpha\'s same-named ticket is in no lane and drops off',
)
})
diff --git a/packages/the-framework/src/dashboard/overview.ts b/packages/the-framework/src/dashboard/overview.ts
index 0d51139b..06a3a4cd 100644
--- a/packages/the-framework/src/dashboard/overview.ts
+++ b/packages/the-framework/src/dashboard/overview.ts
@@ -83,8 +83,8 @@ 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'
+/** Which lane of the "hot tickets" overview (#1139) a ticket sits in. */
+export type HotBucket = 'in-progress' | 'ai-queue' | 'high-priority'
/** One ticket surfaced on the Overview's hot-tickets card, tagged with its project and lane. */
export interface HotTicket {
@@ -102,24 +102,47 @@ export interface HotTicket {
runId?: string
}
-/** Priority values that read as "do this soon" — the "likely next" lane (#1112). */
+/** Priority values that read as "do this soon" — the "high priority" lane (#1139). */
const HIGH_PRIORITY = new Set(['high', 'urgent', 'critical', 'p0', 'p1', '0', '1'])
/**
- * A ticket's lane (#1112):
+ * A ticket's lane (#1139), or null when it is in none of the three the card shows:
* - in-progress: a run is implementing it right now (#1117), or failing that the agent has planned
* or spiked it, so work is under way in the older, inferred sense.
- * - next: no plan/spike yet, but flagged high priority — likely the next thing picked up.
- * - queued: everything else open, the backlog waiting its turn.
+ * - ai-queue: it sits in the AI Queue — an open `TODO_AGENTS.md` entry links to it — so the
+ * framework will pick it up on its own.
+ * - high-priority: none of the above, but flagged high priority; what a human would likely queue next.
*
- * `implementing` outranks the plan/spike proxy rather than replacing it: a live run is the only
- * hard evidence, and it exists for a drain run only, so the inference still carries every ticket
- * someone is working by hand.
+ * Precedence follows that order: work already under way outranks a queued ticket, which outranks a
+ * bare priority flag. Everything else is dropped — the card is a shortlist, not the whole backlog.
+ *
+ * `implementing` is the only hard evidence and exists for a drain run only, so the plan/spike proxy
+ * still carries every ticket someone is working by hand.
*/
-export function ticketBucket(ticket: WorkspaceTicket, implementing = false): HotBucket {
- if (implementing || ticket.planned || ticket.spiked) return 'in-progress'
- if (ticket.priority && HIGH_PRIORITY.has(ticket.priority)) return 'next'
- return 'queued'
+export function ticketBucket(
+ ticket: WorkspaceTicket,
+ opts: { implementing?: boolean; queued?: boolean } = {},
+): HotBucket | null {
+ if (opts.implementing || ticket.planned || ticket.spiked) return 'in-progress'
+ if (opts.queued) return 'ai-queue'
+ if (ticket.priority && HIGH_PRIORITY.has(ticket.priority)) return 'high-priority'
+ return null
+}
+
+/** `[title](target)` at the very start of a queue entry — where a queued ticket's link is written. */
+const QUEUE_LEADING_LINK = /^\s*\[[^\]]+\]\(([^)\s]+)\)/
+
+/**
+ * The ticket file an open queue entry points at, or undefined when it is not a ticket link. Mirrors
+ * the dashboard's `queueEntryLabel`: only a link at the START of the entry names the work, and only
+ * one under `tickets/` is a ticket. Returned as the bare filename, the key {@link WorkspaceTicket.file}
+ * uses.
+ */
+function queuedTicketFile(entry: string): string | undefined {
+ const link = QUEUE_LEADING_LINK.exec(entry)
+ if (!link) return undefined
+ const prefix = `${TICKETS_DIR}/`
+ return link[1]!.startsWith(prefix) ? link[1]!.slice(prefix.length) : undefined
}
/** How many hot tickets the Overview pools before the card trims per lane. */
@@ -130,17 +153,32 @@ export interface HotTicketsDeps {
tickets?: (cwd: string) => Promise
/** The project's live runs, read for the ticket each one recorded (#1117). */
liveRuns?: (cwd: string) => Promise
+ /** The cross-project TODO queue, for the AI-Queue lane (#1139). Defaults to {@link collectQueue}. */
+ queue?: (projects: ProjectSummary[]) => Promise
}
/**
- * 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.
+ * Every project's tickets pooled and bucketed for the Overview's "hot tickets" card (#1139): what is
+ * being worked on (implementing/planned/spiked), what sits in the AI Queue (an open `TODO_AGENTS.md`
+ * entry links to it), and what is merely flagged high priority. Ordered lane-first (in-progress,
+ * ai-queue, high-priority), file order within a lane; a ticket in none of the three is dropped.
+ * Forgiving — a project whose tickets cannot be read simply contributes nothing.
*/
export async function buildHotTickets(projects: ProjectSummary[], deps: HotTicketsDeps = {}): Promise {
const readT = deps.tickets ?? readTickets
const readRuns = deps.liveRuns ?? readLiveMetas
+ // The AI Queue: which tickets an open TODO_AGENTS.md entry links to, per project (#1139).
+ const queues = await (deps.queue ?? (p => collectQueue(p)))(projects)
+ const queuedByProject = new Map>()
+ for (const q of queues) {
+ const files = new Set()
+ for (const item of q.items) {
+ if (item.done) continue
+ const file = queuedTicketFile(item.text)
+ if (file) files.add(file)
+ }
+ queuedByProject.set(q.projectId, files)
+ }
const all: HotTicket[] = []
for (const project of projects) {
// Which of this project's tickets are being implemented right now, by run id (#1117). Built
@@ -150,18 +188,22 @@ export async function buildHotTickets(projects: ProjectSummary[], deps: HotTicke
if (meta.status !== 'running' || !meta.ticket) continue
implementing.set(meta.ticket, meta.id)
}
+ const queued = queuedByProject.get(project.id) ?? new Set()
for (const ticket of await readT(project.path).catch(() => [])) {
const runId = implementing.get(`${TICKETS_DIR}/${ticket.file}`)
+ const bucket = ticketBucket(ticket, { implementing: runId !== undefined, queued: queued.has(ticket.file) })
+ // A ticket in none of the three shown lanes is left off the card entirely.
+ if (!bucket) continue
all.push({
projectId: project.id,
projectName: project.name,
- bucket: ticketBucket(ticket, runId !== undefined),
+ bucket,
ticket,
...(runId ? { runId } : {}),
})
}
}
- const lane: Record = { 'in-progress': 0, next: 1, queued: 2 }
+ const lane: Record = { 'in-progress': 0, 'ai-queue': 1, 'high-priority': 2 }
all.sort((a, b) => lane[a.bucket] - lane[b.bucket])
return all.slice(0, HOT_TICKETS_LIMIT)
}
diff --git a/tickets/2026-07-25_improve-tooltip.md b/tickets/2026-07-25_improve-tooltip.md
index 021c7d88..39d12974 100644
--- a/tickets/2026-07-25_improve-tooltip.md
+++ b/tickets/2026-07-25_improve-tooltip.md
@@ -10,6 +10,17 @@ The custom tooltip is nice, but: (1) show it immediately (no delay), and (2) use
High-priority UX paper cut: tooltip latency and inconsistency make the whole dashboard feel sluggish and unpolished, and the action/settings rows are touched on every session visit. The dropdown grouping decision also stabilizes layout across sessions with different action counts.
+## Status
+
+The OP's two items are done: every tooltip opens with no delay (the shared `TooltipTrigger`
+defaults to `delay={0}`, which beats any provider), and the ~40 remaining native `title=`
+tooltips on buttons, menu items and indicators are the custom one now — including the
+`Open in editor` button the second screenshot was taken on. What is left on the elements is
+`title` as an overflow fallback on `truncate` text, and `title` props on `Section`/`Dialog`.
+
+Still open: the thread's design follow-ups (auto-show the dropdown on hover so the `...`
+button can go, and the settings-row redesign).
+
## Source
Imported from GitHub issue [gemstack-land/the-framework#1149](https://github.com/gemstack-land/the-framework/issues/1149), created 2026-07-25, label: `priority: high`, 4 comments.