feat(sidebar): surface agent status in worktree cards + hover

Renders explicit AgentStatus entries in the existing sidebar:
- WorktreeCard/StatusIndicator pull from the agent-status slice so a
  working/blocked/waiting agent is visible per worktree.
- AgentStateDot is a shared indicator reused by the card + hover.
- AgentStatusHover expands the card on hover with per-agent details.
- smart-sort orders worktrees by live agent activity so the user sees
  hot worktrees at the top.

Pure UI — consumes data produced in the prior PRs.
This commit is contained in:
brennanb2025
2026-04-24 15:34:32 -07:00
parent 1be13d58f3
commit 92b8486b77
9 changed files with 418 additions and 40 deletions
@@ -0,0 +1,87 @@
import React from 'react'
import { cn } from '@/lib/utils'
// Why: shared state-dot primitive so the dashboard and the sidebar's agent
// hover render the same state vocabulary identically. The dot sits next to the
// agent icon (Claude/Codex/etc.) — they are two distinct glyphs: one for *who*
// (icon) and one for *what state* (dot). Keeping them separate keeps each
// glyph scannable at a glance instead of fused into a single decorated icon.
export type AgentDotState =
| 'working'
| 'blocked'
| 'waiting'
| 'done'
| 'idle'
// Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard)
// collapses blocked + waiting into a single "needs attention" state. Keep
// this as a distinct member so that flow can render without inventing a new
// vocabulary, but treat it identically to `blocked` visually.
| 'permission'
export function agentStateLabel(state: AgentDotState): string {
switch (state) {
case 'working':
return 'Working'
case 'blocked':
return 'Blocked'
case 'waiting':
return 'Waiting for input'
case 'done':
return 'Done'
case 'idle':
return 'Idle'
case 'permission':
return 'Needs attention'
}
}
type Props = {
state: AgentDotState
size?: 'sm' | 'md'
className?: string
}
export const AgentStateDot = React.memo(function AgentStateDot({
state,
size = 'sm',
className
}: Props): React.JSX.Element {
const box = size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5'
const inner = size === 'md' ? 'size-2' : 'size-1.5'
if (state === 'working') {
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<span
className={cn(
'block rounded-full border-2 border-emerald-500 border-t-transparent animate-spin',
inner
)}
/>
</span>
)
}
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
aria-label={agentStateLabel(state)}
>
<span
className={cn(
'block rounded-full',
inner,
state === 'blocked' || state === 'waiting' || state === 'permission'
? 'bg-amber-500 animate-pulse'
: state === 'done'
? 'bg-sky-500/80'
: 'bg-neutral-500/40'
)}
/>
</span>
)
})
@@ -0,0 +1,99 @@
import React, { useCallback, useMemo } from 'react'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { useAppStore } from '@/store'
import { useDashboardData } from '@/components/dashboard/useDashboardData'
import { enrichGroupsWithRetained } from '@/components/dashboard/useRetainedAgents'
import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
type AgentStatusHoverProps = {
worktreeId: string
children: React.ReactNode
}
// Why: the hovercard must render the exact same information the per-worktree
// dashboard card shows — hook-reported agents plus any retained "done"
// snapshots. Sharing the dashboard's data pipeline (useDashboardData +
// enrichGroupsWithRetained) and row component (DashboardAgentRow) guarantees
// the two surfaces cannot drift. Retention state itself is hoisted into the
// store (see useRetainedAgentsSync wired at App level), so dismissing in the
// hover reflects in the dashboard and vice versa.
const AgentStatusHover = React.memo(function AgentStatusHover({
worktreeId,
children
}: AgentStatusHoverProps) {
const liveGroups = useDashboardData()
const retained = useAppStore((s) => s.retainedAgentsByPaneKey)
const removeAgentStatus = useAppStore((s) => s.removeAgentStatus)
const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveTab = useAppStore((s) => s.setActiveTab)
const setActiveView = useAppStore((s) => s.setActiveView)
const agents = useMemo(() => {
const enriched = enrichGroupsWithRetained(liveGroups, retained)
for (const group of enriched) {
for (const wt of group.worktrees) {
if (wt.worktree.id === worktreeId) {
return wt.agents
}
}
}
return []
}, [liveGroups, retained, worktreeId])
// Why: mirror AgentDashboard.handleDismissAgent so dismissing in either
// surface has identical effect — removes the live store entry and the
// retained snapshot if either is present.
const handleDismissAgent = useCallback(
(paneKey: string) => {
removeAgentStatus(paneKey)
dismissRetainedAgent(paneKey)
},
[removeAgentStatus, dismissRetainedAgent]
)
// Why: clicking a row activates the specific tab the agent runs in. Retained
// rows can outlive their tab, so fall back to worktree-only activation when
// the tab is no longer present.
const handleActivateAgentTab = useCallback(
(tabId: string) => {
setActiveWorktree(worktreeId)
setActiveView('terminal')
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
if (tabs.some((t) => t.id === tabId)) {
setActiveTab(tabId)
}
},
[worktreeId, setActiveWorktree, setActiveTab, setActiveView]
)
return (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72 p-3 text-xs">
{agents.length === 0 ? (
<div className="py-1 text-center text-muted-foreground">No running agents</div>
) : (
<div className="flex flex-col">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
Running agents ({agents.length})
</div>
<div className="flex flex-col divide-y divide-border/60">
{agents.map((agent) => (
<div key={agent.paneKey} className="py-1">
<DashboardAgentRow
agent={agent}
onDismiss={handleDismissAgent}
onActivate={handleActivateAgentTab}
/>
</div>
))}
</div>
</div>
)}
</HoverCardContent>
</HoverCard>
)
})
export default AgentStatusHover
@@ -1,32 +1,27 @@
import React from 'react'
import { cn } from '@/lib/utils'
import type { WorktreeStatus } from '@/lib/worktree-status'
type StatusIndicatorProps = React.ComponentProps<'span'> & {
status: WorktreeStatus
export type Status = 'active' | 'working' | 'permission' | 'inactive'
type StatusIndicatorProps = {
status: Status
className?: string
}
const StatusIndicator = React.memo(function StatusIndicator({
status,
className,
...props
className
}: StatusIndicatorProps) {
if (status === 'working') {
return (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
{...props}
>
<span className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}>
<span className="block size-2 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
</span>
)
}
return (
<span
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
{...props}
>
<span className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}>
<span
className={cn(
'block size-2 rounded-full',
@@ -42,4 +37,3 @@ const StatusIndicator = React.memo(function StatusIndicator({
})
export default StatusIndicator
export type { WorktreeStatus as Status }
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: the worktree card centralizes sidebar card state (selection, drag, agent status, git info, context menu) in one cohesive component so sidebar rendering doesn't fan out across files. */
import React, { useEffect, useMemo, useCallback, useState } from 'react'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
@@ -7,9 +8,12 @@ import StatusIndicator from './StatusIndicator'
import CacheTimer from './CacheTimer'
import WorktreeContextMenu from './WorktreeContextMenu'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import AgentStatusHover from './AgentStatusHover'
import { cn } from '@/lib/utils'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status'
import { type WorktreeStatus } from '@/lib/worktree-status'
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import type { Worktree, Repo, PRInfo, IssueInfo } from '../../../../shared/types'
import {
@@ -102,6 +106,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const branch = branchDisplayName(worktree.branch)
const isFolder = repo ? isFolderRepo(repo) : false
@@ -121,11 +127,53 @@ const WorktreeCard = React.memo(function WorktreeCard({
const isDeleting = deleteState?.isDeleting ?? false
// Derive status
const status: WorktreeStatus = useMemo(
() => getWorktreeStatus(tabs, browserTabs),
[tabs, browserTabs]
)
// Derive status — explicit agent status (OSC 9999) takes precedence over
// heuristic title parsing, per the design doc's precedence rule.
const status: WorktreeStatus = useMemo(() => {
const liveTabs = tabs.filter((tab) => tab.ptyId)
// Why: browser-only worktrees are still active from the user's point of
// view even when they have no PTY-backed terminal. The sidebar filter
// already treats them as active, so every navigation surface must reuse
// that rule instead of showing a misleading inactive dot.
const hasTerminals = liveTabs.length > 0 || browserTabs.length > 0
if (!hasTerminals) {
return 'inactive'
}
// Why: explicit status only wins while it is fresh. Once it gets stale, the
// design doc says the coarse icon should fall back to live title heuristics
// so an old "done" report does not mask a new permission prompt.
const explicitEntries = Object.values(agentStatusByPaneKey).filter((e) =>
liveTabs.some((t) => e.paneKey.startsWith(`${t.id}:`))
)
const freshEntries = explicitEntries.filter((entry) =>
isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS)
)
if (freshEntries.some((e) => e.state === 'blocked' || e.state === 'waiting')) {
return 'permission'
}
if (freshEntries.some((e) => e.state === 'working')) {
return 'working'
}
// Why done maps to 'active': a completed agent still has a live terminal —
// 'inactive' (gray dot) would incorrectly suggest nothing is there.
if (freshEntries.some((e) => e.state === 'done')) {
return 'active'
}
// Fall through to heuristic title-based detection
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) {
return 'permission'
}
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) {
return 'working'
}
return liveTabs.length > 0 ? 'active' : 'inactive'
// Why: agentStatusEpoch is a cache-busting counter, not data consumed by
// the memo body. It forces re-derivation when an agent status entry crosses
// the freshness threshold so the visual status updates without polling.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabs, browserTabs, agentStatusByPaneKey, agentStatusEpoch])
const showPR = cardProps.includes('pr')
const showCI = cardProps.includes('ci')
@@ -253,7 +301,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* Status indicator on the left */}
{(cardProps.includes('status') || cardProps.includes('unread')) && (
<div className="flex flex-col items-center justify-start pt-[2px] gap-2 shrink-0">
{cardProps.includes('status') && <StatusIndicator status={status} />}
{cardProps.includes('status') && (
<AgentStatusHover worktreeId={worktree.id}>
<span>
<StatusIndicator status={status} />
</span>
</AgentStatusHover>
)}
{cardProps.includes('unread') && (
<Tooltip>
@@ -430,6 +430,7 @@ const WorktreeList = React.memo(function WorktreeList() {
const issueCache = useAppStore((s) => (searchQuery ? s.issueCache : null))
const sortEpoch = useAppStore((s) => s.sortEpoch)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
// Count of non-archived worktrees — used to detect structural changes
// (add/remove) vs. pure reorders (score shifts) so the debounce below
@@ -472,7 +473,7 @@ const WorktreeList = React.memo(function WorktreeList() {
const timer = setTimeout(() => setDebouncedSortEpoch(sortEpoch), SORT_SETTLE_MS)
return () => clearTimeout(timer)
}, [sortEpoch, debouncedSortEpoch, worktreeCount])
}, [sortEpoch, debouncedSortEpoch, worktreeCount, agentStatusEpoch])
// Why a latching ref: we need to distinguish "app just started, no PTYs
// have spawned yet" from "user closed all terminals mid-session." The
@@ -522,14 +523,22 @@ const WorktreeList = React.memo(function WorktreeList() {
const currentTabs = state.tabsByWorktree
nonArchivedWorktrees.sort(
buildWorktreeComparator(sortBy, currentTabs, repoMap, state.prCache, Date.now())
buildWorktreeComparator(
sortBy,
currentTabs,
repoMap,
state.prCache,
Date.now(),
null,
state.agentStatusByPaneKey
)
)
return nonArchivedWorktrees.map((w) => w.id)
// debouncedSortEpoch is an intentional trigger: it's not read inside the
// memo, but its change signals that the sort order should be recomputed.
// The debounce prevents jarring mid-interaction position shifts.
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSortEpoch, repoMap, sortBy])
}, [debouncedSortEpoch, repoMap, sortBy, agentStatusEpoch])
// Persist the computed sort order so the sidebar can be restored after
// restart. Only persist during live sessions (sessionHasHadPty latched) —
@@ -2,6 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import { buildWorktreeComparator, computeSmartScore, type SmartSortOverride } from './smart-sort'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
@@ -53,6 +54,20 @@ function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
}
}
function makeAgentStatusEntry(
overrides: Partial<AgentStatusEntry> & { paneKey: string }
): AgentStatusEntry {
return {
state: overrides.state ?? 'working',
prompt: overrides.prompt ?? '',
updatedAt: overrides.updatedAt ?? NOW - 30_000,
agentType: overrides.agentType ?? 'codex',
paneKey: overrides.paneKey,
terminalTitle: overrides.terminalTitle,
stateHistory: overrides.stateHistory ?? []
}
}
describe('computeSmartScore', () => {
afterEach(() => {
vi.restoreAllMocks()
@@ -158,6 +173,39 @@ describe('computeSmartScore', () => {
computeSmartScore(plain, null, repoMap, {})
)
})
it('does not let stale explicit status mask a live heuristic permission prompt', () => {
const worktree = makeWorktree({ id: 'wt-1' })
const tabsByWorktree = {
[worktree.id]: [makeTab({ worktreeId: worktree.id, title: 'codex permission needed' })]
}
const score = computeSmartScore(worktree, tabsByWorktree, repoMap, null, NOW, {
'tab-1:1': makeAgentStatusEntry({
paneKey: 'tab-1:1',
state: 'done',
updatedAt: NOW - 45 * 60_000
})
})
expect(score).toBeGreaterThanOrEqual(35)
})
it('does not stack heuristic working on top of fresh explicit done for the same tab', () => {
const worktree = makeWorktree({ id: 'wt-1' })
const tabsByWorktree = {
[worktree.id]: [makeTab({ worktreeId: worktree.id, title: 'codex working' })]
}
expect(
computeSmartScore(worktree, tabsByWorktree, repoMap, null, NOW, {
'tab-1:1': makeAgentStatusEntry({
paneKey: 'tab-1:1',
state: 'done',
updatedAt: NOW - 60_000
})
})
).toBe(12)
})
})
describe('buildWorktreeComparator', () => {
@@ -1,6 +1,10 @@
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { branchName } from '@/lib/git-utils'
import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
type SortBy = 'name' | 'smart' | 'recent' | 'repo'
@@ -35,18 +39,72 @@ function computeSmartScoreFromSignals(
worktree: Worktree,
tabs: TerminalTab[],
hasRecentPR: boolean,
now: number
now: number,
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
): number {
const liveTabs = tabs.filter((t) => t.ptyId)
let score = 0
const isRunning = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'working')
// Why: explicit agent status (OSC 9999) is authoritative over heuristic title
// parsing. Check explicit status first; fall through to heuristics for tabs
// that have no explicit status entry.
const explicitEntries = agentStatusByPaneKey
? Object.values(agentStatusByPaneKey).filter((e) =>
tabs.some((t) => e.paneKey.startsWith(`${t.id}:`))
)
: []
const explicitByTabId = new Map<string, AgentStatusEntry[]>()
for (const entry of explicitEntries) {
const colonIndex = entry.paneKey.indexOf(':')
const tabId = colonIndex === -1 ? entry.paneKey : entry.paneKey.slice(0, colonIndex)
const existing = explicitByTabId.get(tabId)
if (existing) {
existing.push(entry)
} else {
explicitByTabId.set(tabId, [entry])
}
}
let hasExplicitWorking = false
let hasExplicitBlocked = false
let hasHeuristicWorking = false
let hasHeuristicBlocked = false
for (const tab of liveTabs) {
const tabExplicitEntries = explicitByTabId.get(tab.id) ?? []
const hasFreshExplicit = tabExplicitEntries.some((entry) =>
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
)
if (hasFreshExplicit) {
hasExplicitWorking ||= tabExplicitEntries.some(
(entry) =>
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) &&
entry.state === 'working'
)
hasExplicitBlocked ||= tabExplicitEntries.some(
(entry) =>
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) &&
(entry.state === 'blocked' || entry.state === 'waiting')
)
continue
}
const heuristicState = detectAgentStatusFromTitle(tab.title)
hasHeuristicWorking ||= heuristicState === 'working'
hasHeuristicBlocked ||= heuristicState === 'permission'
}
// Explicit working → +60, same weight as heuristic working
// Explicit blocked/waiting → +35, same weight as heuristic permission
// Explicit done → no bonus (task complete, no attention needed)
const isRunning = hasExplicitWorking || hasHeuristicWorking
if (isRunning) {
score += 60
}
const needsAttention = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'permission')
const needsAttention = hasExplicitBlocked || hasHeuristicBlocked
if (needsAttention) {
score += 35
}
@@ -106,7 +164,8 @@ export function buildWorktreeComparator(
repoMap: Map<string, Repo>,
prCache: Record<string, PRCacheEntry> | null,
now: number = Date.now(),
smartSortOverrides: Record<string, SmartSortOverride> | null = null
smartSortOverrides: Record<string, SmartSortOverride> | null = null,
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
): (a: Worktree, b: Worktree) => number {
return (a, b) => {
switch (sortBy) {
@@ -132,13 +191,15 @@ export function buildWorktreeComparator(
smartB.worktree,
smartB.tabs,
smartB.hasRecentPRSignal,
now
now,
agentStatusByPaneKey
) -
computeSmartScoreFromSignals(
smartA.worktree,
smartA.tabs,
smartA.hasRecentPRSignal,
now
now,
agentStatusByPaneKey
) ||
smartB.worktree.lastActivityAt - smartA.worktree.lastActivityAt ||
a.displayName.localeCompare(b.displayName)
@@ -179,7 +240,8 @@ export function sortWorktreesSmart(
worktrees: Worktree[],
tabsByWorktree: Record<string, TerminalTab[]>,
repoMap: Map<string, Repo>,
prCache: Record<string, PRCacheEntry> | null
prCache: Record<string, PRCacheEntry> | null,
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
): Worktree[] {
const hasAnyLivePty = Object.values(tabsByWorktree)
.flat()
@@ -192,8 +254,18 @@ export function sortWorktreesSmart(
)
}
// Why: agentStatusByPaneKey is forwarded so the smart-score comparator can
// use explicit agent status (OSC 9999) when ranking worktrees by recency.
return [...worktrees].sort(
buildWorktreeComparator('smart', tabsByWorktree, repoMap, prCache, Date.now())
buildWorktreeComparator(
'smart',
tabsByWorktree,
repoMap,
prCache,
Date.now(),
null,
agentStatusByPaneKey
)
)
}
@@ -215,7 +287,8 @@ export function computeSmartScore(
tabsByWorktree: Record<string, TerminalTab[]> | null,
repoMap: Map<string, Repo> | null,
prCache: Record<string, PRCacheEntry> | null,
now: number = Date.now()
now: number = Date.now(),
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
): number {
return computeSmartScoreFromSignals(
worktree,
@@ -225,6 +298,7 @@ export function computeSmartScore(
// only while that branch cache entry is still cold so smart sorting stays
// stable on launch without reviving stale PRs after a cache miss resolves.
repoMap ? hasRecentPRSignal(worktree, repoMap, prCache) : worktree.linkedPR !== null,
now
now,
agentStatusByPaneKey
)
}
@@ -120,9 +120,13 @@ export function getVisibleWorktreeIds(): string[] {
let sortedIds: string[]
if (state.sortBy === 'smart') {
sortedIds = sortWorktreesSmart(allWorktrees, state.tabsByWorktree, repoMap, state.prCache).map(
(w) => w.id
)
sortedIds = sortWorktreesSmart(
allWorktrees,
state.tabsByWorktree,
repoMap,
state.prCache,
state.agentStatusByPaneKey
).map((w) => w.id)
} else {
const sorted = [...allWorktrees].sort(
buildWorktreeComparator(
@@ -130,7 +134,9 @@ export function getVisibleWorktreeIds(): string[] {
state.tabsByWorktree,
repoMap,
state.prCache,
Date.now()
Date.now(),
null,
state.agentStatusByPaneKey
)
)
sortedIds = sorted.map((w) => w.id)
+8 -1
View File
@@ -255,9 +255,16 @@ export function AgentIcon({
agent,
size = 14
}: {
agent: TuiAgent
agent: TuiAgent | null | undefined
size?: number
}): React.JSX.Element {
// Why: render a neutral question-mark glyph when the agent identity is not
// yet known. Before, the caller coerced null → 'claude', which caused Codex
// panes to briefly show the Claude icon until the first hook callback
// arrived.
if (!agent) {
return <AgentLetterIcon letter="?" size={size} />
}
if (agent === 'claude') {
return <ClaudeIcon size={size} />
}