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

* 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".

* refactor(dashboard): head the card with the session name, move the worktree beside the project

The conversation name now sits next to the agent icon as the card's heading
rather than prefixing the user-message line, and the worktree drops to the
footer beside the project icon. The message line reads "You" again — the name
moved up, so keeping it there said the same thing twice.

Cards without a resolvable session name keep the worktree as the heading, and
the footer omits it rather than repeating it.

* fix(dashboard): thread settings into every snapshot builder caller

Adding `settings` to DashboardSnapshotState left three callers constructing it
without one. The in-window drawer's was a real defect, not just a type error:
useLiveDashboardSnapshot derives its own snapshot rather than receiving the
relayed one, so a dropped slice silently blanks generated conversation names
in the drawer while the pop-out shows them.

Bucket counts pass null deliberately — they never render a conversation name,
so the sidebar stays unsubscribed from settings.

Covers the drawer's wiring with a test, since `settings: null` type-checks and
would blank names again without failing loudly.

* test(dashboard): complete the terminal layout fixture

TerminalLayoutSnapshot requires expandedLeafId; the neighbouring builder test
hides this behind an `as unknown as` cast on the whole state object.
This commit is contained in:
Brennan Benson 2026-07-27 17:13:23 -07:00 committed by GitHub
parent a72068015f
commit 025c242f0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 635 additions and 87 deletions

View File

@ -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({

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)
)
}

View File

@ -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.

View File

@ -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>
)
}

View File

@ -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,160 @@ 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('heads the card with the conversation name and drops the worktree to the footer', () => {
const { container } = renderCard({
card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }),
now: 2_000
})
const [header, footer] = [
container.querySelector('button')!.firstElementChild!,
container.querySelector('button')!.lastElementChild!
]
expect(header).toHaveTextContent('Sparse-checkout parser')
expect(header).not.toHaveTextContent('dashboard-review')
expect(footer).toHaveTextContent('dashboard-review')
// The message line is attributed to the user again — the name moved up.
expect(screen.getByText('You')).toBeInTheDocument()
})
it('heads the card with the worktree when no name resolves, without repeating it', () => {
const { container } = renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 })
expect(screen.getAllByText('dashboard-review')).toHaveLength(1)
expect(container.querySelector('button')!.firstElementChild).toHaveTextContent(
'dashboard-review'
)
})
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 () => {

View File

@ -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,23 @@ 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'
// Why: the session's own name heads the card. Without one the worktree is
// the best heading left — and then the footer drops it rather than say it
// twice.
const heading = card.conversationName ?? card.worktreeName
const worktreeInFooter = card.conversationName !== undefined
return (
<button
@ -80,9 +120,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">
@ -99,7 +143,7 @@ export const AgentKanbanCard = memo(
card.unseen ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground'
)}
>
{card.worktreeName}
{heading}
</span>
{/* The summary pill already carries the attention glyph. */}
{card.askSummary ? null : <AgentStateDot state={card.dotState} className="ml-auto" />}
@ -109,6 +153,7 @@ 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">
{/* Why: plain "You" again — the session's name now heads the card. */}
<span className="font-medium text-foreground/45">
{translate('dashboardPopout.card.you', 'You')}
</span>{' '}
@ -128,17 +173,34 @@ 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>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{/* Why: the project reads as an icon so its name can't crowd the
worktree sitting next to it; the name 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>
{worktreeInFooter ? <span className="truncate">{card.worktreeName}</span> : null}
{displayTimestamp(card) > 0 ? (
<span className="ml-auto shrink-0 tabular-nums">
<span className="ml-auto shrink-0 pl-1 tabular-nums">
{formatStartedAgo(displayTimestamp(card), now)}
</span>
) : null}
@ -149,6 +211,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))

View File

@ -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({

View File

@ -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 }
}

View File

@ -56,7 +56,10 @@ export function useAgentBucketCounts(): AgentBucketCounts {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
// Counts do not render acknowledgement state, so avoid subscribing the sidebar to it.
acknowledgedAgentsByPaneKey: {}
acknowledgedAgentsByPaneKey: {},
// Same: counts never render a card's conversation name, so the
// generated-title gate is moot and the sidebar stays off settings.
settings: null
},
Date.now()
)

View File

@ -56,6 +56,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
acknowledgedAgentsByPaneKey: {},
settings: null,
agentStatusEpoch: 0
}
}

View File

@ -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
)

View File

@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { GlobalSettings, Repo, TerminalTab, Worktree } from '../../../../shared/types'
import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
const NOW = 1_000_000_000
const TAB_ID = 'tab-1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
const initialAppState = useAppStore.getInitialState()
beforeEach(() => {
useAppStore.setState(initialAppState, true)
})
afterEach(() => {
useAppStore.setState(initialAppState, true)
})
function repo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Repo One',
badgeColor: '#000',
repoIcon: { type: 'lucide', name: 'Rocket' },
addedAt: 1
}
}
function worktree(): Worktree {
return {
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/wt-1',
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: false,
displayName: 'wt-one',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW
}
}
function tab(): TerminalTab {
return {
id: TAB_ID,
ptyId: 'pty-1',
worktreeId: 'wt-1',
title: 'agent',
customTitle: null,
generatedTitle: 'Fix the flaky pty test',
color: null,
sortOrder: 0,
createdAt: NOW
} as TerminalTab
}
function entry(): AgentStatusEntry {
return {
paneKey: PANE_KEY,
state: 'working',
prompt: 'do the thing',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
stateHistory: [],
agentType: 'claude',
tabId: TAB_ID,
worktreeId: 'wt-1'
}
}
function seed(settings: Partial<GlobalSettings> | null): void {
useAppStore.setState({
repos: [repo()],
worktreesByRepo: { 'repo-1': [worktree()] },
tabsByWorktree: { 'wt-1': [tab()] },
agentStatusByPaneKey: { [PANE_KEY]: entry() },
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
}
},
ptyIdsByTabId: { [TAB_ID]: ['pty-1'] },
settings: settings as GlobalSettings | null
})
}
// Why: the in-window drawer derives its own snapshot instead of receiving the
// relayed one, so anything the builder reads has to be threaded in by hand —
// a dropped slice silently blanks the field rather than failing loudly.
describe('useLiveDashboardSnapshot', () => {
it('feeds the builder the settings that gate generated conversation names', () => {
seed({ tabAutoGenerateTitle: true })
const withTitles = renderHook(() => useLiveDashboardSnapshot())
expect(withTitles.result.current.cards[0].conversationName).toBe('Fix the flaky pty test')
seed({ tabAutoGenerateTitle: false })
const withoutTitles = renderHook(() => useLiveDashboardSnapshot())
expect(withoutTitles.result.current.cards[0].conversationName).toBeUndefined()
})
it('carries repo icons through to the drawer', () => {
seed({ tabAutoGenerateTitle: false })
const { result } = renderHook(() => useLiveDashboardSnapshot())
expect(result.current.repoIconsByRepoId).toEqual({
'repo-1': { type: 'lucide', name: 'Rocket' }
})
})
})

View File

@ -23,6 +23,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
// Why: gates generated tab titles in the cards' conversation names.
const settings = useAppStore((s) => s.settings)
// Why: freshness can flip a bucket without any backing map changing; the epoch
// ticks on the freshness boundary so the memo re-derives stale-decayed cards.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
@ -43,7 +45,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey
acknowledgedAgentsByPaneKey,
settings
},
Date.now()
),
@ -60,6 +63,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey,
settings,
agentStatusEpoch
]
)

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: