mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user