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:
parent
a72068015f
commit
025c242f0c
|
|
@ -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,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 () => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
|
|||
ptyIdsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
settings: null,
|
||||
agentStatusEpoch: 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue