feat(dashboard): tint agent cards by state, show the project as an icon, name the chat

Three glanceability changes to the agent board:

- The "Needs You" signal moves from the column border onto the cards
  themselves, and done agents get the same treatment in green. Idle cards
  that simply aren't running stay neutral, so a tint always means "this one
  wants you".
- The repo is now its own icon with the name in a tooltip, instead of a
  mono label that truncated and competed with the worktree name. Icons ride
  the snapshot keyed by repoId — image icons are data URLs, and the
  snapshot republishes several times a second.
- The user-message line is labelled with the tab's conversation name rather
  than "You", resolved through the same getAgentRowConversationName the
  sidebar's agent rows use. Status-only titles still fall back to "You".
This commit is contained in:
Brennan Benson
2026-07-27 16:14:23 -07:00
parent c0734f039d
commit f3473f2b79
10 changed files with 485 additions and 84 deletions
@@ -50,6 +50,56 @@ describe('dashboard payload validation', () => {
).toBe(false)
})
it('accepts repo icons a pop-out can safely render, and rejects the rest', () => {
expect(
isDashboardSnapshot({
...SNAPSHOT,
repoIconsByRepoId: {
'repo-1': { type: 'lucide', name: 'Rocket' },
'repo-2': null,
'repo-3': {
type: 'image',
src: 'https://github.com/anthropics.png?size=64',
source: 'github'
}
}
})
).toBe(true)
// Absent entirely: a pop-out on older code still gets its snapshot.
expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: undefined })).toBe(true)
expect(
isDashboardSnapshot({
...SNAPSHOT,
repoIconsByRepoId: {
'repo-1': { type: 'image', src: 'javascript:alert(1)', source: 'file' }
}
})
).toBe(false)
expect(
isDashboardSnapshot({
...SNAPSHOT,
repoIconsByRepoId: { 'repo-1': { type: 'nonsense' } }
})
).toBe(false)
expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: [] })).toBe(false)
})
it('bounds the conversation name', () => {
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], conversationName: 'Sparse-checkout parser' }]
})
).toBe(true)
expect(
isDashboardSnapshot({
...SNAPSHOT,
cards: [{ ...SNAPSHOT.cards[0], conversationName: 'x'.repeat(1_025) }]
})
).toBe(false)
})
it('requires complete bounded reveal routing', () => {
expect(
isDashboardRevealAgentArgs({
+26 -2
View File
@@ -1,4 +1,5 @@
import type { DashboardRevealAgentArgs, DashboardSnapshot } from '../../shared/dashboard-snapshot'
import { sanitizeRepoIcon } from '../../shared/repo-icon'
import {
AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
@@ -7,6 +8,7 @@ import {
} from '../../shared/agent-status-types'
const MAX_DASHBOARD_CARDS = 1_000
const MAX_DASHBOARD_REPO_ICONS = 500
const MAX_ID_LENGTH = 4_096
const MAX_LABEL_LENGTH = 1_024
const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'idle'])
@@ -50,7 +52,28 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot
isFiniteNumber(snapshot.generatedAt) &&
Array.isArray(snapshot.cards) &&
snapshot.cards.length <= MAX_DASHBOARD_CARDS &&
snapshot.cards.every(isDashboardCard)
snapshot.cards.every(isDashboardCard) &&
isDashboardRepoIcons(snapshot.repoIconsByRepoId)
)
}
/** Repo icons reach the pop-out's `<img src>`, so each one must survive the
* same sanitizer the settings picker writes through. */
function isDashboardRepoIcons(value: unknown): boolean {
if (value === undefined) {
return true
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const entries = Object.entries(value as Record<string, unknown>)
return (
entries.length <= MAX_DASHBOARD_REPO_ICONS &&
entries.every(
([repoId, icon]) =>
isBoundedString(repoId, MAX_ID_LENGTH) &&
(icon === null || sanitizeRepoIcon(icon) !== undefined)
)
)
}
@@ -80,6 +103,7 @@ function isDashboardCard(value: unknown): boolean {
(card.finishedAt === null || isFiniteNumber(card.finishedAt)) &&
isFiniteNumber(card.stateChangedAt) &&
typeof card.unseen === 'boolean' &&
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH)
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) &&
isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH)
)
}
@@ -4,6 +4,7 @@ import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { AgentKanbanBoard } from './AgentKanbanBoard'
// Stub the card and dialog so the board test stays free of xterm / Radix
@@ -11,10 +12,12 @@ import { AgentKanbanBoard } from './AgentKanbanBoard'
vi.mock('./AgentKanbanCard', () => ({
AgentKanbanCard: ({
card,
repoIcon,
now,
onOpenTerminal
}: {
card: DashboardCard
repoIcon?: RepoIcon | null
now: number
onOpenTerminal: (card: DashboardCard) => void
}) => (
@@ -23,6 +26,7 @@ vi.mock('./AgentKanbanCard', () => ({
data-bucket={card.bucket}
data-unseen={card.unseen}
data-now={now}
data-repo-icon={repoIcon === null ? 'none' : JSON.stringify(repoIcon)}
onClick={() => onOpenTerminal(card)}
>
{card.worktreeName}
@@ -70,8 +74,11 @@ function card(overrides: Partial<DashboardCard>): DashboardCard {
}
}
function renderBoard(cards: DashboardCard[]): void {
const snapshot: DashboardSnapshot = { generatedAt: 1, cards }
function renderBoard(
cards: DashboardCard[],
repoIconsByRepoId?: Record<string, RepoIcon | null>
): void {
const snapshot: DashboardSnapshot = { generatedAt: 1, cards, repoIconsByRepoId }
render(<AgentKanbanBoard snapshot={snapshot} />)
}
@@ -108,6 +115,30 @@ describe('AgentKanbanBoard', () => {
expect(screen.getByText('3 total')).toBeTruthy()
})
it('leaves every column border neutral now that cards carry the state color', () => {
renderBoard([card({ bucket: 'attention' })])
for (const column of document.querySelectorAll('section')) {
expect(column.className).toContain('border-border/60')
expect(column.className).not.toContain('amber')
}
})
it('routes each card its own repo icon', () => {
renderBoard(
[
card({ repoId: 'r1', worktreeName: 'from-r1' }),
card({ repoId: 'r2', worktreeName: 'from-r2' }),
card({ repoId: 'r3', worktreeName: 'from-r3' })
],
{ r1: { type: 'lucide', name: 'Rocket' }, r2: null }
)
expect(screen.getByText('from-r1').dataset.repoIcon).toBe('{"type":"lucide","name":"Rocket"}')
expect(screen.getByText('from-r2').dataset.repoIcon).toBe('none')
// Unknown repo → the card's own default glyph, never another repo's icon.
expect(screen.getByText('from-r3').dataset.repoIcon).toBe('none')
})
it('shows "None" for empty columns', () => {
renderBoard([card({ bucket: 'working' })])
// attention and idle are empty → two "None" placeholders.
@@ -6,7 +6,9 @@ import {
type DashboardCard,
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { cn } from '@/lib/utils'
import { TooltipProvider } from '@/components/ui/tooltip'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { AgentKanbanCard } from './AgentKanbanCard'
import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog'
@@ -58,22 +60,20 @@ function groupByBucket(cards: DashboardCard[]): Record<DashboardBucket, Dashboar
function KanbanColumn({
bucket,
cards,
repoIconsByRepoId,
now,
onOpenTerminal
}: {
bucket: DashboardBucket
cards: DashboardCard[]
repoIconsByRepoId: Record<string, RepoIcon | null> | undefined
now: number
onOpenTerminal: (card: DashboardCard) => void
}): React.JSX.Element {
const highlight = bucket === 'attention' && cards.length > 0
return (
<section
className={cn(
'flex min-w-[264px] flex-1 flex-col rounded-xl border bg-muted/30',
highlight ? 'border-amber-500/40' : 'border-border/60'
)}
>
// Why: attention no longer tints the whole column — the cards inside carry
// their own state color, so a column border would double-signal it.
<section className="flex min-w-[264px] flex-1 flex-col rounded-xl border border-border/60 bg-muted/30">
<header className="flex items-center gap-2 px-3 py-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{bucketLabel(bucket)}
@@ -92,6 +92,7 @@ function KanbanColumn({
<AgentKanbanCard
key={card.paneKey}
card={card}
repoIcon={repoIconsByRepoId?.[card.repoId] ?? null}
now={now}
onOpenTerminal={onOpenTerminal}
/>
@@ -192,54 +193,60 @@ export function AgentKanbanBoard({
}, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent])
return (
<div className={cn('flex flex-col bg-background text-foreground', containerClassName)}>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-[13px] font-semibold">
{translate('dashboardPopout.title', 'Agents')}
</h1>
<span className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.total', '{{count}} total', {
count: snapshot.cards.length
})}
</span>
{headerActions || onClose ? (
<div className="ml-auto flex items-center gap-1">
{headerActions}
{onClose ? (
<button
type="button"
onClick={onClose}
aria-label={translate('dashboardPopout.close', 'Close dashboard')}
className="rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<XIcon className="size-4" />
</button>
) : null}
</div>
) : null}
</div>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Why: columns share the window width up to a readable cap; mx-auto
// Why: the pop-out is its own React root with no app-level provider, and the
// card's repo tooltip needs one in both hosts. Nesting inside the main
// window's provider is harmless.
<TooltipProvider delayDuration={300}>
<div className={cn('flex flex-col bg-background text-foreground', containerClassName)}>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-[13px] font-semibold">
{translate('dashboardPopout.title', 'Agents')}
</h1>
<span className="text-[11px] text-muted-foreground">
{translate('dashboardPopout.total', '{{count}} total', {
count: snapshot.cards.length
})}
</span>
{headerActions || onClose ? (
<div className="ml-auto flex items-center gap-1">
{headerActions}
{onClose ? (
<button
type="button"
onClick={onClose}
aria-label={translate('dashboardPopout.close', 'Close dashboard')}
className="rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<XIcon className="size-4" />
</button>
) : null}
</div>
) : null}
</div>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Why: columns share the window width up to a readable cap; mx-auto
centers the capped board so leftover space splits evenly instead of
pooling on the right. In overflow the auto margins collapse to 0,
keeping the left edge reachable while scrolling. */}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{DASHBOARD_BUCKET_ORDER.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
{DASHBOARD_BUCKET_ORDER.map((bucket) => (
<KanbanColumn
key={bucket}
bucket={bucket}
cards={grouped[bucket]}
repoIconsByRepoId={snapshot.repoIconsByRepoId}
now={now}
onOpenTerminal={handleOpenTerminal}
/>
))}
</div>
</div>
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
</div>
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
</div>
</TooltipProvider>
)
}
@@ -4,7 +4,9 @@ import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { i18n } from '@/i18n/i18n'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AgentKanbanCard } from './AgentKanbanCard'
const agentIconRender = vi.fn()
@@ -42,6 +44,24 @@ function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
}
}
function renderCard(props: {
card: DashboardCard
now: number
repoIcon?: RepoIcon | null
onOpenTerminal?: () => void
}): ReturnType<typeof render> {
return render(
<TooltipProvider>
<AgentKanbanCard
card={props.card}
repoIcon={props.repoIcon}
now={props.now}
onOpenTerminal={props.onOpenTerminal ?? vi.fn()}
/>
</TooltipProvider>
)
}
describe('AgentKanbanCard', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
@@ -53,9 +73,7 @@ describe('AgentKanbanCard', () => {
})
it('does not render an invented age when the start time is unknown', () => {
render(
<AgentKanbanCard card={card({ startedAt: 0 })} now={2_000_000_000} onOpenTerminal={vi.fn()} />
)
renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 })
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
})
@@ -66,47 +84,147 @@ describe('AgentKanbanCard', () => {
dotState: 'waiting',
askSummary: 'Approve deploy?'
})
const { container, rerender } = render(
<AgentKanbanCard card={attentionCard} now={2_000} onOpenTerminal={vi.fn()} />
)
const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 })
expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument()
expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1)
rerender(
<AgentKanbanCard
card={{ ...attentionCard, askSummary: undefined }}
now={2_000}
onOpenTerminal={vi.fn()}
/>
<TooltipProvider>
<AgentKanbanCard
card={{ ...attentionCard, askSummary: undefined }}
now={2_000}
onOpenTerminal={vi.fn()}
/>
</TooltipProvider>
)
expect(screen.getByTestId('state-dot')).toBeInTheDocument()
expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull()
})
it('tints attention amber and done green, leaving every other state neutral', () => {
const { container: attention } = renderCard({
card: card({ bucket: 'attention', dotState: 'waiting' }),
now: 2_000
})
expect(attention.querySelector('button')?.className).toContain('border-amber-500/40')
cleanup()
const { container: done } = renderCard({
card: card({ bucket: 'idle', dotState: 'done' }),
now: 2_000
})
expect(done.querySelector('button')?.className).toContain('border-emerald-500/40')
cleanup()
const { container: idle } = renderCard({
card: card({ bucket: 'idle', dotState: 'idle' }),
now: 2_000
})
const idleClassName = idle.querySelector('button')?.className ?? ''
expect(idleClassName).toContain('border-border/60')
expect(idleClassName).not.toContain('emerald')
expect(idleClassName).not.toContain('amber')
})
it('labels the user message with the conversation name, falling back to "You"', () => {
renderCard({
card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }),
now: 2_000
})
expect(screen.getByText('Sparse-checkout parser')).toBeInTheDocument()
expect(screen.queryByText('You')).not.toBeInTheDocument()
cleanup()
renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 })
expect(screen.getByText('You')).toBeInTheDocument()
})
it('shows the repo as an icon labelled with its name instead of inline text', () => {
renderCard({
card: card(),
now: 2_000,
repoIcon: { type: 'emoji', emoji: '🐳' }
})
expect(screen.getByLabelText('Orca')).toBeInTheDocument()
expect(screen.getByText('🐳')).toBeInTheDocument()
})
it('skips structured-clone rerenders until visible card data or its age changes', () => {
const onOpenTerminal = vi.fn()
const initial = card({ startedAt: 1_000 })
const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' }
const { rerender } = render(
<AgentKanbanCard card={initial} now={61_500} onOpenTerminal={onOpenTerminal} />
<TooltipProvider>
<AgentKanbanCard
card={initial}
repoIcon={repoIcon}
now={61_500}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(1)
expect(screen.getByText('1m')).toBeInTheDocument()
rerender(<AgentKanbanCard card={{ ...initial }} now={62_000} onOpenTerminal={onOpenTerminal} />)
// A fresh structured clone of identical data — including the repo icon.
rerender(
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial }}
repoIcon={{ ...repoIcon }}
now={62_000}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(1)
rerender(
<AgentKanbanCard card={{ ...initial }} now={121_500} onOpenTerminal={onOpenTerminal} />
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial }}
repoIcon={{ ...repoIcon }}
now={121_500}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(2)
expect(screen.getByText('2m')).toBeInTheDocument()
})
it('updates the relative age when the UI language changes', async () => {
render(
<AgentKanbanCard card={card({ startedAt: 1_000 })} now={121_500} onOpenTerminal={vi.fn()} />
it('rerenders when the repo icon changes', () => {
const onOpenTerminal = vi.fn()
const initial = card({ startedAt: 1_000 })
const { rerender } = render(
<TooltipProvider>
<AgentKanbanCard
card={initial}
repoIcon={{ type: 'lucide', name: 'Rocket' }}
now={61_500}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(1)
rerender(
<TooltipProvider>
<AgentKanbanCard
card={{ ...initial }}
repoIcon={{ type: 'lucide', name: 'Database' }}
now={61_500}
onOpenTerminal={onOpenTerminal}
/>
</TooltipProvider>
)
expect(agentIconRender).toHaveBeenCalledTimes(2)
})
it('updates the relative age when the UI language changes', async () => {
renderCard({ card: card({ startedAt: 1_000 }), now: 121_500 })
expect(screen.getByText('2m')).toBeInTheDocument()
await act(async () => {
@@ -4,8 +4,11 @@ import { MessageCircleQuestion } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import { AgentStateDot } from '@/components/AgentStateDot'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { translate } from '@/i18n/i18n'
/** Compact "started N ago" (the card is glanceable — coarse units are fine). */
@@ -53,12 +56,34 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean {
a.finishedAt === b.finishedAt &&
a.stateChangedAt === b.stateChangedAt &&
a.unseen === b.unseen &&
a.askSummary === b.askSummary
a.askSummary === b.askSummary &&
a.conversationName === b.conversationName
)
}
/** Structural — the icon arrives inside a fresh structured clone each publish,
* so identity alone would re-render every card several times a second. */
function sameRepoIcon(a: RepoIcon | null | undefined, b: RepoIcon | null | undefined): boolean {
if (a === b) {
return true
}
if (!a || !b || a.type !== b.type) {
return false
}
if (a.type === 'lucide') {
return a.name === (b as typeof a).name
}
if (a.type === 'emoji') {
return a.emoji === (b as typeof a).emoji
}
const image = b as typeof a
return a.src === image.src && a.source === image.source && a.label === image.label
}
type AgentKanbanCardProps = {
card: DashboardCard
/** The card repo's icon. null renders the default folder glyph. */
repoIcon?: RepoIcon | null
now: number
/** Opens the board-level terminal dialog. The dialog is NOT owned by the
* card: bucket moves remount the card, and an embedded dialog would close
@@ -68,8 +93,18 @@ type AgentKanbanCardProps = {
/** One agent on the kanban board. Clicking opens the board's live terminal dialog. */
export const AgentKanbanCard = memo(
function AgentKanbanCard({ card, now, onOpenTerminal }: AgentKanbanCardProps): React.JSX.Element {
function AgentKanbanCard({
card,
repoIcon = null,
now,
onOpenTerminal
}: AgentKanbanCardProps): React.JSX.Element {
useTranslation()
// Why: the two outcomes worth scanning for get a tinted card — amber for
// "answer me", green for "finished, look at it". Everything else stays
// neutral so the tint keeps meaning something.
const needsYou = card.bucket === 'attention'
const isDone = card.dotState === 'done'
return (
<button
@@ -80,9 +115,13 @@ export const AgentKanbanCard = memo(
// paneKey has ':'/'/' which aren't valid in a custom-ident, so slugify.
style={{ viewTransitionName: `agentcard-${card.paneKey.replace(/[^a-zA-Z0-9]/g, '-')}` }}
className={cn(
'group flex w-full flex-col gap-1.5 rounded-lg border border-border/60 bg-card p-2.5 text-left',
'transition-colors hover:border-border hover:bg-accent/40',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
'group flex w-full flex-col gap-1.5 rounded-lg border p-2.5 text-left',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
needsYou
? 'border-amber-500/40 bg-amber-500/[0.06] hover:border-amber-500/60 hover:bg-amber-500/10'
: isDone
? 'border-emerald-500/40 bg-emerald-500/[0.06] hover:border-emerald-500/60 hover:bg-emerald-500/10'
: 'border-border/60 bg-card hover:border-border hover:bg-accent/40'
)}
>
<div className="flex items-center gap-1.5">
@@ -106,8 +145,15 @@ export const AgentKanbanCard = memo(
<div className="flex flex-col gap-0.5">
{card.lastUserMessage ? (
<div className="line-clamp-1 text-[11px] leading-snug text-muted-foreground">
<span className="font-medium text-foreground/45">
{translate('dashboardPopout.card.you', 'You')}
{/* Why: the chat's own name identifies the conversation far
better than "You" does; fall back when none resolves. */}
<span
className={cn(
'font-medium',
card.conversationName ? 'text-foreground/70' : 'text-foreground/45'
)}
>
{card.conversationName ?? translate('dashboardPopout.card.you', 'You')}
</span>{' '}
{card.lastUserMessage}
</div>
@@ -125,15 +171,31 @@ export const AgentKanbanCard = memo(
<div className="line-clamp-2 text-xs leading-snug text-foreground/90">{card.task}</div>
) : null}
{/* Why: the card behind it is amber now, so the pill needs its own edge
to stay a distinct chip instead of a flat block of tint. */}
{card.askSummary ? (
<div className="flex items-start gap-1 rounded-md bg-amber-500/10 px-1.5 py-1 text-[11px] text-amber-600 dark:text-amber-400">
<div className="flex items-start gap-1 rounded-md bg-amber-500/15 px-1.5 py-1 text-[11px] text-amber-600 ring-1 ring-inset ring-amber-500/25 dark:text-amber-400">
<MessageCircleQuestion className="mt-px size-3 shrink-0" aria-hidden />
<span className="line-clamp-2">{card.askSummary}</span>
</div>
) : null}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate font-mono">{card.repoName}</span>
{/* Why: the repo's own icon never truncates and never competes with
the worktree name; the name itself lives in the tooltip. */}
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex size-[18px] shrink-0 items-center justify-center rounded-[5px] bg-muted-foreground/10 text-muted-foreground transition-colors group-hover:text-foreground"
aria-label={card.repoName}
>
<RepoIconGlyph repoIcon={repoIcon} className="size-3" iconClassName="size-3" />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{card.repoName}
</TooltipContent>
</Tooltip>
{displayTimestamp(card) > 0 ? (
<span className="ml-auto shrink-0 tabular-nums">
{formatStartedAgo(displayTimestamp(card), now)}
@@ -146,6 +208,7 @@ export const AgentKanbanCard = memo(
(previous, next) =>
previous.onOpenTerminal === next.onOpenTerminal &&
sameCard(previous.card, next.card) &&
sameRepoIcon(previous.repoIcon, next.repoIcon) &&
(displayTimestamp(previous.card) <= 0 ||
formatStartedAgo(displayTimestamp(previous.card), previous.now) ===
formatStartedAgo(displayTimestamp(next.card), next.now))
@@ -114,6 +114,73 @@ describe('buildDashboardSnapshot', () => {
expect(card.unseen).toBe(true)
})
it('carries the tab conversation name and drops status-only titles', () => {
const named = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
tabsByWorktree: { w1: [{ ...tab(), customTitle: 'Sparse-checkout parser' }] }
}),
NOW
)
expect(named.cards[0].conversationName).toBe('Sparse-checkout parser')
// The fixture tab's title is the 'agent' placeholder — not a name.
const unnamed = buildDashboardSnapshot(
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
NOW
)
expect(unnamed.cards[0].conversationName).toBeUndefined()
})
it('withholds generated titles until the setting enables them', () => {
const tabs = { w1: [{ ...tab(), generatedTitle: 'Fix the flaky pty test' }] }
const off = buildDashboardSnapshot(
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, tabsByWorktree: tabs }),
NOW
)
expect(off.cards[0].conversationName).toBeUndefined()
const on = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
tabsByWorktree: tabs,
settings: { tabAutoGenerateTitle: true }
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
expect(on.cards[0].conversationName).toBe('Fix the flaky pty test')
})
it('ships one icon per card-bearing repo, and none for repos without cards', () => {
const snapshot = buildDashboardSnapshot(
baseState({
repos: [
{
id: 'r1',
path: '/r1',
displayName: 'Repo One',
badgeColor: '#000',
repoIcon: { type: 'lucide', name: 'Rocket' }
},
{ id: 'r2', path: '/r2', displayName: 'Repo Two', badgeColor: '#000' }
],
worktreesByRepo: { r1: [worktree()], r2: [worktree('w2', 'wt-two')] },
agentStatusByPaneKey: { [PANE_KEY]: entry({}) }
} as unknown as Partial<DashboardSnapshotState>),
NOW
)
// r2 has a worktree but no agent card, so its icon never ships.
expect(snapshot.repoIconsByRepoId).toEqual({ r1: { type: 'lucide', name: 'Rocket' } })
})
it('records a null icon for a card-bearing repo that has none', () => {
const snapshot = buildDashboardSnapshot(
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
NOW
)
expect(snapshot.repoIconsByRepoId).toEqual({ r1: null })
})
it('nulls ptyId when the layout entry points at a dead pty', () => {
const snapshot = buildDashboardSnapshot(
baseState({
@@ -5,7 +5,9 @@ import type {
DashboardCardDotState,
DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { applyAgentRowLineage } from './agent-row-lineage'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
@@ -43,6 +45,7 @@ export type DashboardSnapshotState = Pick<
| 'ptyIdsByTabId'
| 'runtimePaneTitlesByTabId'
| 'acknowledgedAgentsByPaneKey'
| 'settings'
>
function bucketForState(state: DashboardAgentRow['state']): DashboardBucket {
@@ -70,6 +73,24 @@ function nonEmpty(value: string | undefined): string | undefined {
return trimmed.length > 0 ? trimmed : undefined
}
/** Mirrors useAgentRowConversationName so the board and the sidebar label the
* same agent with the same name. */
function rowConversationName(
row: DashboardAgentRow,
generatedTitlesEnabled: boolean
): string | undefined {
const parentPaneKey = row.entry.orchestration?.parentPaneKey
// Why: a child row rendered on its parent's tab does not own that tab's name.
if (
row.lineage?.depth === 1 &&
parentPaneKey !== undefined &&
parsePaneKey(parentPaneKey)?.tabId === row.tab.id
) {
return undefined
}
return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined
}
/**
* Derive the serializable dashboard snapshot from the live renderer store.
* Reuses the exact per-worktree row machinery the sidebar uses
@@ -82,6 +103,8 @@ export function buildDashboardSnapshot(
now: number
): DashboardSnapshot {
const cards: DashboardCard[] = []
const repoIconsByRepoId: Record<string, RepoIcon | null> = {}
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const activeWorktrees: {
repo: AppState['repos'][number]
worktree: AppState['worktreesByRepo'][string][number]
@@ -169,6 +192,8 @@ export function buildDashboardSnapshot(
: null
const dotState = row.state as DashboardCardDotState
const bucket = bucketForState(row.state)
// Only repos that actually contribute a card ship their icon.
repoIconsByRepoId[repo.id] = repo.repoIcon ?? null
cards.push({
paneKey: row.paneKey,
@@ -193,10 +218,11 @@ export function buildDashboardSnapshot(
unseen:
!isTitleDerived &&
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt,
askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined
askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined,
conversationName: rowConversationName(row, generatedTitlesEnabled)
})
}
}
return { generatedAt: now, cards }
return { generatedAt: now, cards, repoIconsByRepoId }
}
@@ -26,6 +26,8 @@ export function dashboardSnapshotInputsChanged(
state.ptyIdsByTabId !== previousState.ptyIdsByTabId ||
state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId ||
state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey ||
// Why: tabAutoGenerateTitle decides whether cards may show generated names.
state.settings !== previousState.settings ||
// Why: freshness can change a bucket without replacing any backing map.
state.agentStatusEpoch !== previousState.agentStatusEpoch
)
+14 -1
View File
@@ -1,4 +1,5 @@
import type { AgentType } from './agent-status-types'
import type { RepoIcon } from './repo-icon'
/**
* Serializable contract for the pop-out agent dashboard. The main renderer owns
@@ -58,14 +59,26 @@ export type DashboardCard = {
unseen: boolean
/** Short summary of the pending question when bucket === 'attention'. */
askSummary?: string
/** The tab's conversation name, resolved exactly as the sidebar's agent rows
* resolve it. Undefined when no usable name exists (status-only titles). */
conversationName?: string
}
export type DashboardSnapshot = {
generatedAt: number
cards: DashboardCard[]
/** Icons for the repos the cards belong to. Keyed by repoId rather than
* carried per card: image icons are data URLs up to 400KB, and the snapshot
* is republished several times a second. Optional so a pop-out running
* pre-upgrade code still accepts the payload. */
repoIconsByRepoId?: Record<string, RepoIcon | null>
}
export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = { generatedAt: 0, cards: [] }
export const EMPTY_DASHBOARD_SNAPSHOT: DashboardSnapshot = {
generatedAt: 0,
cards: [],
repoIconsByRepoId: {}
}
/** Routing payload for click-to-focus: reveal this agent's pane in the main
* window. leafId is null when the pane could not be resolved (best-effort: