Show prompt cache timers on compact agent rows (#6295)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-24 16:27:36 -07:00 committed by GitHub
parent 3f775c34c1
commit 23cde7bef0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 512 additions and 66 deletions

View File

@ -335,9 +335,7 @@ describe('useEditorPanelContentState', () => {
secondRead.resolve({ content: 'fresh content', isBinary: false })
await secondRead.promise
})
await vi.waitFor(() =>
expect(latestFileContents[activeFile.id]?.content).toBe('fresh content')
)
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('fresh content'))
})
it('ignores an older file read that resolves after a newer forced read', async () => {
@ -364,9 +362,7 @@ describe('useEditorPanelContentState', () => {
freshRead.resolve({ content: 'fresh content', isBinary: false })
await freshRead.promise
})
await vi.waitFor(() =>
expect(latestFileContents[activeFile.id]?.content).toBe('fresh content')
)
await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('fresh content'))
// The older read resolving last must not clobber the fresh content.
await act(async () => {
@ -418,7 +414,10 @@ describe('useEditorPanelContentState', () => {
await vi.waitFor(() => expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1))
await act(async () => {
conflictRead.resolve({ content: '<<<<<<< HEAD\ncurrent\n=======\nincoming\n>>>>>>> branch', isBinary: false })
conflictRead.resolve({
content: '<<<<<<< HEAD\ncurrent\n=======\nincoming\n>>>>>>> branch',
isBinary: false
})
await conflictRead.promise
})

View File

@ -137,9 +137,5 @@ export function useGitStatusFileWatchRefresh({
}
window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleFsChanged as EventListener)
}
}, [
activeRuntimeEnvironmentId,
shouldSubscribe,
worktreePath
])
}, [activeRuntimeEnvironmentId, shouldSubscribe, worktreePath])
}

View File

@ -246,9 +246,7 @@ describe('useGitStatusPolling', () => {
it('filters filesystem payloads to files inside the active worktree', async () => {
vi.resetModules()
const { shouldRefreshGitStatusForFileChange } = await import(
'./git-status-file-watch-refresh'
)
const { shouldRefreshGitStatusForFileChange } = await import('./git-status-file-watch-refresh')
expect(
shouldRefreshGitStatusForFileChange(

View File

@ -0,0 +1,90 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { usePromptCacheCountdownStartedAt } from './CacheTimer'
const scanMocks = vi.hoisted(() => ({
getMostUrgentPromptCacheStartedAt: vi.fn(() => 1_000)
}))
type MockState = {
cacheTimerByKey: Record<string, number | null>
settings?: {
promptCacheTimerEnabled?: boolean
promptCacheTtlMs?: number
}
tabsByWorktree: Record<string, { id: string }[]>
}
let mockState: MockState
vi.mock('@/store', () => ({
useAppStore: (selector: (state: MockState) => unknown) => selector(mockState)
}))
vi.mock('./prompt-cache-timer-selection', () => ({
getMostUrgentPromptCacheStartedAt: scanMocks.getMostUrgentPromptCacheStartedAt,
getPromptCacheCountdownForPane: vi.fn(() => null)
}))
function AggregateTimerProbe({ active = true }: { active?: boolean }): React.JSX.Element {
const startedAt = usePromptCacheCountdownStartedAt('wt-1', active)
return <span>{startedAt ?? 'none'}</span>
}
describe('usePromptCacheCountdownStartedAt', () => {
beforeEach(() => {
scanMocks.getMostUrgentPromptCacheStartedAt.mockClear()
mockState = {
cacheTimerByKey: { 'tab-1:11111111-1111-4111-8111-111111111111': 1_000 },
settings: {
promptCacheTimerEnabled: true,
promptCacheTtlMs: 60_000
},
tabsByWorktree: {
'wt-1': [{ id: 'tab-1' }]
}
}
})
it('does not scan aggregate cache timers while inactive', () => {
const markup = renderToStaticMarkup(<AggregateTimerProbe active={false} />)
expect(markup).toContain('none')
expect(scanMocks.getMostUrgentPromptCacheStartedAt).not.toHaveBeenCalled()
})
it('does not scan aggregate cache timers while disabled', () => {
mockState.settings = {
promptCacheTimerEnabled: false,
promptCacheTtlMs: 60_000
}
const markup = renderToStaticMarkup(<AggregateTimerProbe />)
expect(markup).toContain('none')
expect(scanMocks.getMostUrgentPromptCacheStartedAt).not.toHaveBeenCalled()
})
it('does not scan aggregate cache timers when ttl is zero', () => {
mockState.settings = {
promptCacheTimerEnabled: true,
promptCacheTtlMs: 0
}
const markup = renderToStaticMarkup(<AggregateTimerProbe />)
expect(markup).toContain('none')
expect(scanMocks.getMostUrgentPromptCacheStartedAt).not.toHaveBeenCalled()
})
it('scans aggregate cache timers only when the timer can render', () => {
const markup = renderToStaticMarkup(<AggregateTimerProbe />)
expect(markup).toContain('1000')
expect(scanMocks.getMostUrgentPromptCacheStartedAt).toHaveBeenCalledTimes(1)
expect(scanMocks.getMostUrgentPromptCacheStartedAt).toHaveBeenCalledWith(
mockState.tabsByWorktree['wt-1'],
mockState.cacheTimerByKey
)
})
})

View File

@ -2,8 +2,13 @@ import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { Timer } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { useShallow } from 'zustand/react/shallow'
import { usePromptCacheCountdownNow } from './prompt-cache-countdown-clock'
import { getMostUrgentPromptCacheStartedAt } from './prompt-cache-timer-selection'
import {
getMostUrgentPromptCacheStartedAt,
getPromptCacheCountdownForPane,
type PromptCacheCountdownSelection
} from './prompt-cache-timer-selection'
/**
* The most-urgent cache start time when a countdown should show, else null.
@ -14,15 +19,45 @@ import { getMostUrgentPromptCacheStartedAt } from './prompt-cache-timer-selectio
* (shortest remaining) start time if any tab's cache is about to expire, the
* user should know.
*/
export function usePromptCacheCountdownStartedAt(worktreeId: string): number | null {
const enabled = useAppStore((s) => s.settings?.promptCacheTimerEnabled ?? false)
const ttlMs = useAppStore((s) => s.settings?.promptCacheTtlMs ?? 0)
const startedAt = useAppStore((s) =>
getMostUrgentPromptCacheStartedAt(s.tabsByWorktree[worktreeId], s.cacheTimerByKey)
export function usePromptCacheCountdownStartedAt(worktreeId: string, active = true): number | null {
const [enabled, ttlMs, startedAt] = useAppStore(
useShallow((s) => {
if (!active) {
return [false, 0, null] as const
}
const enabled = s.settings?.promptCacheTimerEnabled ?? false
const ttlMs = s.settings?.promptCacheTtlMs ?? 0
if (!enabled || ttlMs <= 0) {
return [enabled, ttlMs, null] as const
}
return [
enabled,
ttlMs,
getMostUrgentPromptCacheStartedAt(s.tabsByWorktree[worktreeId], s.cacheTimerByKey)
] as const
})
)
return enabled && ttlMs > 0 && startedAt != null ? startedAt : null
}
export function usePromptCacheCountdownForPane(
paneKey: string,
active = true
): PromptCacheCountdownSelection | null {
return useAppStore(
useShallow((s) => {
if (!active || !(s.settings?.promptCacheTimerEnabled ?? false)) {
return null
}
return getPromptCacheCountdownForPane(
paneKey,
s.cacheTimerByKey,
s.settings?.promptCacheTtlMs ?? 0
)
})
)
}
/**
* Per-worktree prompt-cache countdown, shown in the sidebar worktree card. The
* card renders this only once a cache is active, so it's a pure countdown view.

View File

@ -1,6 +1,7 @@
import { renderToStaticMarkup } from 'react-dom/server'
import React, { type ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { GlobalSettings, Repo, Worktree, WorktreeCardProperty } from '../../../../shared/types'
import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports'
@ -14,17 +15,23 @@ const updateWorktreeMeta = vi.fn()
const recordFeatureInteraction = vi.fn()
const setWorkspacePortScan = vi.fn()
const setWorkspacePortScanRefreshing = vi.fn()
const cacheTimerMocks = vi.hoisted(() => ({
usePromptCacheCountdownStartedAt: vi.fn()
}))
let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports']
let hostedReviewCache: Record<string, unknown> = {}
let projectGroups: unknown[] = []
let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null
let settings: Partial<GlobalSettings> | null = { compactWorktreeCards: true }
let agentActivityDisplayMode: 'compact' | 'full' | undefined
let mockInlineAgentRows: DashboardAgentRowData[] = []
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
browserTabsByWorktree: {},
agentActivityDisplayMode,
createBrowserTab: vi.fn(),
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
@ -90,12 +97,16 @@ vi.mock('./use-worktree-activity-status', () => ({
vi.mock('./CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: () => null
usePromptCacheCountdownStartedAt: cacheTimerMocks.usePromptCacheCountdownStartedAt
}))
vi.mock('./useWorktreeAgentRows', () => ({
useWorktreeAgentRows: vi.fn(() => mockInlineAgentRows)
}))
vi.mock('./WorktreeCardAgents', () => ({
default: ({ className }: { className?: string }) => (
<div className={className} data-worktree-agents="" />
default: ({ className, agents }: { className?: string; agents?: DashboardAgentRowData[] }) => (
<div className={className} data-agent-count={agents?.length ?? ''} data-worktree-agents="" />
)
}))
@ -177,6 +188,9 @@ describe('WorktreeCard compact hover details', () => {
projectGroups = []
workspacePortScan = null
settings = { compactWorktreeCards: true }
agentActivityDisplayMode = undefined
mockInlineAgentRows = []
cacheTimerMocks.usePromptCacheCountdownStartedAt.mockReturnValue(null)
})
it('shows PR and live port details from the compact worktree card hover', async () => {
@ -519,6 +533,65 @@ describe('WorktreeCard compact hover details', () => {
expect(markup).not.toContain('data-worktree-agents')
})
it('does not create a compact metadata row solely for an aggregate cache timer', async () => {
settings = { compactWorktreeCards: true }
worktreeCardProperties = ['status']
cacheTimerMocks.usePromptCacheCountdownStartedAt.mockImplementation(
(_worktreeId: string, active = true) => (active ? 10_000 : null)
)
const worktree = makeWorktree()
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(
worktree.id,
false
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
})
it('suppresses the aggregate cache timer when compact inline agents are visible', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'inline-agents']
agentActivityDisplayMode = 'compact'
mockInlineAgentRows = [{} as DashboardAgentRowData]
const worktree = makeWorktree()
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('data-worktree-agents=""')
expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(
worktree.id,
false
)
})
it('preserves the aggregate cache timer when compact inline agents are enabled but absent', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'inline-agents']
agentActivityDisplayMode = 'compact'
mockInlineAgentRows = []
cacheTimerMocks.usePromptCacheCountdownStartedAt.mockImplementation(
(_worktreeId: string, active = true) => (active ? 10_000 : null)
)
const worktree = makeWorktree()
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('data-worktree-agents=""')
expect(markup).toContain('data-agent-count="0"')
expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(worktree.id, true)
})
it('keeps child card markup outside the parent hover trigger when new card style is on', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'comment']

View File

@ -23,6 +23,7 @@ import WorktreeContextMenu from './WorktreeContextMenu'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { AutoRenameFailedDialog } from './AutoRenameFailedDialog'
import WorktreeCardAgents from './WorktreeCardAgents'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import { cn } from '@/lib/utils'
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
@ -75,6 +76,7 @@ import { translate } from '@/i18n/i18n'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
type WorktreeRenameRequest = {
worktreeId: string
@ -231,6 +233,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
const fetchIssue = useAppStore((s) => s.fetchIssue)
const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
const agentActivityDisplayMode =
useAppStore((s) => s.agentActivityDisplayMode) ?? DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE
const projectGroups = useAppStore((s) => s.projectGroups)
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
const compactCards = !newCardStyle && settings?.compactWorktreeCards === true
@ -959,6 +963,16 @@ const WorktreeCard = React.memo(function WorktreeCard({
const metaReview = showPR ? hoverReview : null
const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null
const metaComment = showComment ? hoverComment : null
const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards)
const compactInlineAgentRows = useWorktreeAgentRows(
worktree.id,
showInlineAgentList && agentActivityDisplayMode === 'compact'
)
const compactInlineAgentRowsVisible =
showInlineAgentList &&
agentActivityDisplayMode === 'compact' &&
compactInlineAgentRows.length > 0
const showAggregateCacheTimer = !compactCards && !compactInlineAgentRowsVisible
const handleOpenGitHubIssueInOrca = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@ -1051,8 +1065,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
automationProvenance: metaAutomationProvenance
})
const hasPorts = showPorts && workspacePorts.length > 0
const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id)
const cacheTtlMs = useAppStore((s) => s.settings?.promptCacheTtlMs ?? 0)
const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id, showAggregateCacheTimer)
const cacheTtlMs = useAppStore((s) =>
showAggregateCacheTimer ? (s.settings?.promptCacheTtlMs ?? 0) : 0
)
// Why: pinned trees mix repos in one section; a leading repo icon keeps the
// list scannable, so it shows regardless of groupBy's hideRepoBadge.
const showPinnedRepoIcon = inPinnedSection && !!repo
@ -1118,7 +1134,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
? trimmedVisibleCardTitle
: undefined
const hasHoverIdentity = Boolean(hoverWorkspaceTitle || hoverBranchName)
const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards)
const hasHoverDetails =
newCardStyle &&
(hasWorktreeCardDetails({
@ -1626,6 +1641,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
{showInlineAgentList && (
<WorktreeCardAgents
worktreeId={worktree.id}
agents={agentActivityDisplayMode === 'compact' ? compactInlineAgentRows : undefined}
className={hasMetaRow || remoteBranchConflict ? 'mt-0' : '-mt-1'}
/>
)}

View File

@ -4,6 +4,10 @@ import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { makePaneKey } from '../../../../shared/stable-pane-id'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
type MockAgentOptions = {
paneKey?: string
@ -66,6 +70,9 @@ function mockAgent({
let mockAgents: unknown[] = [mockAgent()]
let mockFocusedAgentPaneKey: string | null = null
let mockAgentActivityDisplayMode: 'compact' | 'full' | undefined
let mockPromptCacheTimerEnabled = true
let mockPromptCacheTtlMs = 60_000
let mockCacheTimerByKey: Record<string, number | null> = {}
let capturedRowActivations: {
paneKey: string
onActivate: (tabId: string, paneKey: string) => void
@ -81,6 +88,7 @@ vi.mock('@/store', () => ({
selector({
agentActivityDisplayMode: mockAgentActivityDisplayMode,
acknowledgedAgentsByPaneKey: {},
cacheTimerByKey: mockCacheTimerByKey,
dropAgentStatus: vi.fn(),
dismissRetainedAgent: vi.fn(),
acknowledgeAgents: vi.fn(),
@ -88,7 +96,11 @@ vi.mock('@/store', () => ({
agentStatusByPaneKey: {},
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sendPromptToSidebarAgentTarget: vi.fn()
sendPromptToSidebarAgentTarget: vi.fn(),
settings: {
promptCacheTimerEnabled: mockPromptCacheTimerEnabled,
promptCacheTtlMs: mockPromptCacheTtlMs
}
})
}))
@ -108,6 +120,10 @@ vi.mock('@/components/dashboard/useNow', () => ({
useNow: vi.fn(() => 2000)
}))
vi.mock('./prompt-cache-countdown-clock', () => ({
usePromptCacheCountdownNow: vi.fn(() => 10_000)
}))
vi.mock('@/components/dashboard/DashboardAgentRow', () => ({
default: ({
agent,
@ -177,6 +193,9 @@ describe('WorktreeCardAgents', () => {
mockAgents = [mockAgent()]
mockFocusedAgentPaneKey = null
mockAgentActivityDisplayMode = undefined
mockPromptCacheTimerEnabled = true
mockPromptCacheTtlMs = 60_000
mockCacheTimerByKey = {}
capturedRowActivations = []
})
@ -246,6 +265,100 @@ describe('WorktreeCardAgents', () => {
expect(markup).not.toContain('<span class="text-muted-foreground/90">Focused prompt</span>')
})
it('shows a matching pane prompt-cache timer before the compact row age', async () => {
mockAgentActivityDisplayMode = 'compact'
const paneKey = makePaneKey('tab-1', LEAF_A)
mockAgents = [
mockAgent({
paneKey,
tabId: 'tab-1',
agentType: 'claude',
startedAt: 1000,
prompt: 'Resume Claude'
})
]
mockCacheTimerByKey = { [paneKey]: 10_000 }
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
const timerIndex = markup.indexOf('Prompt cache expires in 1:00')
const ageIndex = markup.indexOf('>now</span>')
expect(timerIndex).toBeGreaterThanOrEqual(0)
expect(ageIndex).toBeGreaterThanOrEqual(0)
expect(timerIndex).toBeLessThan(ageIndex)
})
it('does not show a prompt-cache timer on a nonmatching compact row', async () => {
mockAgentActivityDisplayMode = 'compact'
const paneKey = makePaneKey('tab-1', LEAF_A)
const otherPaneKey = makePaneKey('tab-1', LEAF_B)
mockAgents = [
mockAgent({
paneKey,
tabId: 'tab-1',
agentType: 'claude',
startedAt: 1000,
prompt: 'No timer here'
})
]
mockCacheTimerByKey = { [otherPaneKey]: 10_000 }
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('No timer here')
expect(markup).not.toContain('Prompt cache expires')
})
it('does not show a prompt-cache timer when the feature is disabled', async () => {
mockAgentActivityDisplayMode = 'compact'
mockPromptCacheTimerEnabled = false
const paneKey = makePaneKey('tab-1', LEAF_A)
mockAgents = [
mockAgent({
paneKey,
tabId: 'tab-1',
agentType: 'claude',
startedAt: 1000,
prompt: 'Disabled timer'
})
]
mockCacheTimerByKey = { [paneKey]: 10_000 }
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('Disabled timer')
expect(markup).not.toContain('Prompt cache expires')
})
it('keeps hidden retained compact rows from rendering prompt-cache timers', async () => {
const paneKey = makePaneKey('tab-1', LEAF_A)
mockCacheTimerByKey = { [paneKey]: 10_000 }
const { CompactAgentRow } = await import('./worktree-card-compact-agents')
const markup = renderToStaticMarkup(
<CompactAgentRow
agent={
mockAgent({
paneKey,
tabId: 'tab-1',
agentType: 'claude',
startedAt: 1000,
prompt: 'Collapsed child'
}) as DashboardAgentRowData
}
now={2000}
onActivate={vi.fn()}
cacheTimerActive={false}
/>
)
expect(markup).toContain('Collapsed child')
expect(markup).not.toContain('Prompt cache expires')
})
it('marks only the focused agent row', async () => {
mockAgentActivityDisplayMode = 'full'
mockFocusedAgentPaneKey = 'tab-1:2'
@ -431,6 +544,37 @@ describe('WorktreeCardAgents', () => {
expect(markup).not.toContain('data-testid="agent-row"')
})
it('does not show a prompt-cache timer on a collapsed compact summary row', async () => {
mockAgentActivityDisplayMode = 'compact'
const paneKey = makePaneKey('tab-1', LEAF_A)
mockAgents = [
mockAgent({
paneKey,
tabId: 'tab-1',
agentType: 'codex',
state: 'done',
startedAt: 1000,
prompt: 'First agent'
}),
mockAgent({
paneKey: makePaneKey('tab-1', LEAF_B),
tabId: 'tab-1',
agentType: 'claude',
state: 'done',
startedAt: 1500,
prompt: 'Second agent'
})
]
mockCacheTimerByKey = { [paneKey]: 10_000 }
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('All 2 agents done')
expect(markup).not.toContain('Prompt cache expires')
expect(markup).not.toContain('compact-agent-row')
})
it('keeps compact agent messages with trusted data image markdown to the single-line preview', async () => {
mockAgentActivityDisplayMode = 'compact'
mockAgents = [

View File

@ -42,6 +42,7 @@ function revealCompactAgentCard(agentListRoot: HTMLElement | null): void {
type Props = {
worktreeId: string
agents?: DashboardAgentRowData[]
/** Controls spacing from the card body above. Passed in so the parent can
* decide whether a divider is appropriate e.g. suppressed when the card
* chrome already provides visual separation. */
@ -58,9 +59,11 @@ type Props = {
*/
const WorktreeCardAgents = React.memo(function WorktreeCardAgents({
worktreeId,
agents: precomputedAgents,
className
}: Props) {
const agents = useWorktreeAgentRows(worktreeId)
const selectedAgents = useWorktreeAgentRows(worktreeId, precomputedAgents === undefined)
const agents = precomputedAgents ?? selectedAgents
if (agents.length === 0) {
return null
}
@ -356,7 +359,8 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
const renderCompactAgentBranch = (
agent: DashboardAgentRowData,
ancestorPaneKeys: ReadonlySet<string> = new Set()
ancestorPaneKeys: ReadonlySet<string> = new Set(),
cacheTimerActive = true
): React.ReactNode => {
if (ancestorPaneKeys.has(agent.paneKey)) {
return null
@ -391,12 +395,17 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}
reserveDisclosureGutter={isRootAgent && anyRootHasChildren && !hasChildAgents}
isFocusedPane={agent.paneKey === focusedAgentPaneKey}
cacheTimerActive={cacheTimerActive}
/>
{hasChildAgents ? (
<CompactAgentExpansion expanded={expanded}>
<div className="worktree-agent-lineage-children flex flex-col gap-0.5">
{childAgents.map((childAgent) =>
renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys)
renderCompactAgentBranch(
childAgent,
descendantAncestorPaneKeys,
cacheTimerActive && expanded
)
)}
</div>
</CompactAgentExpansion>
@ -444,7 +453,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}}
/>
<CompactAgentExpansion expanded={compactRootListExpanded}>
{rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))}
{rootAgents.map((rootAgent) =>
renderCompactAgentBranch(rootAgent, new Set(), compactRootListExpanded)
)}
</CompactAgentExpansion>
</div>
) : (

View File

@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
import { getMostUrgentPromptCacheStartedAt } from './prompt-cache-timer-selection'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import {
getMostUrgentPromptCacheStartedAt,
getPromptCacheCountdownForPane
} from './prompt-cache-timer-selection'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
describe('getMostUrgentPromptCacheStartedAt', () => {
it('selects the oldest non-null timer for the worktree tabs in one cache pass', () => {
@ -23,3 +30,40 @@ describe('getMostUrgentPromptCacheStartedAt', () => {
expect(startedAt).toBe(300)
})
})
describe('getPromptCacheCountdownForPane', () => {
it('selects the exact pane timer with the ttl used for gating', () => {
const paneKey = makePaneKey('tab-1', LEAF_A)
const otherPaneKey = makePaneKey('tab-1', LEAF_B)
expect(
getPromptCacheCountdownForPane(
paneKey,
{
[paneKey]: 300,
[otherPaneKey]: 100
},
5000
)
).toEqual({ startedAt: 300, ttlMs: 5000 })
})
it('does not fall back to seed timers for per-pane row ownership', () => {
const paneKey = makePaneKey('tab-1', LEAF_A)
expect(getPromptCacheCountdownForPane(paneKey, { 'tab-1:seed': 300 }, 5000)).toBeNull()
})
it('rejects malformed pane keys and null timer values', () => {
const paneKey = makePaneKey('tab-1', LEAF_A)
expect(getPromptCacheCountdownForPane('tab-1:1', { 'tab-1:1': 300 }, 5000)).toBeNull()
expect(getPromptCacheCountdownForPane(paneKey, { [paneKey]: null }, 5000)).toBeNull()
})
it('requires a positive ttl', () => {
const paneKey = makePaneKey('tab-1', LEAF_A)
expect(getPromptCacheCountdownForPane(paneKey, { [paneKey]: 300 }, 0)).toBeNull()
})
})

View File

@ -1,4 +1,10 @@
import type { TerminalTab } from '../../../../shared/types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
export type PromptCacheCountdownSelection = {
startedAt: number
ttlMs: number
}
function getCacheTimerTabId(key: string): string | null {
const separator = key.indexOf(':')
@ -28,3 +34,15 @@ export function getMostUrgentPromptCacheStartedAt(
}
return oldest
}
export function getPromptCacheCountdownForPane(
paneKey: string,
cacheTimerByKey: Record<string, number | null>,
ttlMs: number
): PromptCacheCountdownSelection | null {
if (ttlMs <= 0 || parsePaneKey(paneKey) === null) {
return null
}
const startedAt = cacheTimerByKey[paneKey]
return startedAt == null ? null : { startedAt, ttlMs }
}

View File

@ -35,42 +35,47 @@ export {
* store slice and then shared by every visible card, avoiding O(cards × agents)
* selector work on high-frequency agent status pings.
*/
export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId])
export function useWorktreeAgentRows(worktreeId: string, active = true): DashboardAgentRow[] {
const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : undefined))
// Why: narrow the subscriptions to only THIS worktree's entries via
// useShallow. Subscribing to the whole agentStatusByPaneKey map would make
// every on-screen card re-render on any agent-status update anywhere —
// O(worktrees²) render amplification. Pre-filtering here means the card
// only re-renders when something relevant to THIS worktree changes.
const liveEntries = useAppStore(
useShallow((s) => selectLiveAgentStatusEntriesForWorktree(s, worktreeId))
useShallow((s) => (active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : []))
)
// Why: keep the store selector limited to stable raw records. Converting
// migration entries creates fresh objects with Date.now(), which breaks
// useSyncExternalStore's cached-snapshot contract and can blank Electron.
const migrationUnsupported = useAppStore(
useShallow((s) => selectMigrationUnsupportedEntriesForWorktree(s, worktreeId))
useShallow((s) => (active ? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId) : []))
)
const retained = useAppStore(
useShallow((s) => selectRetainedAgentEntriesForWorktree(s, worktreeId))
useShallow((s) => (active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : []))
)
const runtimePaneTitlesByTabId = useAppStore(
useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktreeId))
useShallow((s) => (active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : {}))
)
const ptyIdsByTabId = useAppStore(
useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : {}))
)
const ptyIdsByTabId = useAppStore(useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId)))
const terminalLayoutsByTabId = useAppStore(
useShallow((s) => selectTerminalLayoutsForWorktree(s, worktreeId))
useShallow((s) => (active ? selectTerminalLayoutsForWorktree(s, worktreeId) : {}))
)
const runtimeAgentOrchestrationByPaneKey = useAppStore(
useShallow((s) => selectRuntimeAgentOrchestrationForWorktree(s, worktreeId))
useShallow((s) => (active ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) : {}))
)
// Why: agentStatusEpoch is included in the dependency array (but not in the
// computation itself) so the memo recomputes when freshness boundaries
// expire, even if no new PTY data arrives — same rationale as
// useDashboardData.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const agentStatusEpoch = useAppStore((s) => (active ? s.agentStatusEpoch : 0))
return useMemo<DashboardAgentRow[]>(() => {
if (!active) {
return []
}
// Why: Date.now() is read inside the memo (not as a dep) so stale-decay
// recalculates whenever agentStatusEpoch ticks — same pattern as
// useDashboardData.
@ -99,6 +104,7 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
active,
tabs,
liveEntries,
migrationUnsupported,

View File

@ -12,6 +12,9 @@ const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = []
const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = []
const EMPTY_RETAINED: RetainedAgentEntry[] = []
const EMPTY_RUNTIME_AGENT_ORCHESTRATION: Record<string, AgentStatusOrchestrationContext> = {}
// Why: selector unit tests often pass partial store mocks; production state
// owns these maps, but missing mock maps should behave like empty slices.
const EMPTY_RECORD = {}
type WorktreeAgentRowsState = Pick<
AppState,
@ -77,17 +80,19 @@ function getTabIdToWorktreeId(
}
function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map<string, AgentStatusEntry[]> {
const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_RECORD
const tabsByWorktree = state.tabsByWorktree ?? EMPTY_RECORD
if (
liveEntriesByWorktreeCache?.tabsByWorktree === state.tabsByWorktree &&
liveEntriesByWorktreeCache.agentStatusByPaneKey === state.agentStatusByPaneKey
liveEntriesByWorktreeCache?.tabsByWorktree === tabsByWorktree &&
liveEntriesByWorktreeCache.agentStatusByPaneKey === agentStatusByPaneKey
) {
return liveEntriesByWorktreeCache.entriesByWorktree
}
const tabIdToWorktreeId = getTabIdToWorktreeId(state.tabsByWorktree)
const tabIdToWorktreeId = getTabIdToWorktreeId(tabsByWorktree)
const previous = liveEntriesByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map<string, AgentStatusEntry[]>()
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
continue
@ -107,8 +112,8 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map<string, Ag
entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries))
}
liveEntriesByWorktreeCache = {
tabsByWorktree: state.tabsByWorktree,
agentStatusByPaneKey: state.agentStatusByPaneKey,
tabsByWorktree,
agentStatusByPaneKey,
entriesByWorktree
}
return entriesByWorktree
@ -117,18 +122,19 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map<string, Ag
function getMigrationUnsupportedByWorktree(
state: WorktreeAgentRowsState
): Map<string, MigrationUnsupportedPtyEntry[]> {
const migrationUnsupportedByPtyId = state.migrationUnsupportedByPtyId ?? EMPTY_RECORD
const tabsByWorktree = state.tabsByWorktree ?? EMPTY_RECORD
if (
migrationUnsupportedByWorktreeCache?.tabsByWorktree === state.tabsByWorktree &&
migrationUnsupportedByWorktreeCache.migrationUnsupportedByPtyId ===
state.migrationUnsupportedByPtyId
migrationUnsupportedByWorktreeCache?.tabsByWorktree === tabsByWorktree &&
migrationUnsupportedByWorktreeCache.migrationUnsupportedByPtyId === migrationUnsupportedByPtyId
) {
return migrationUnsupportedByWorktreeCache.entriesByWorktree
}
const tabIdToWorktreeId = getTabIdToWorktreeId(state.tabsByWorktree)
const tabIdToWorktreeId = getTabIdToWorktreeId(tabsByWorktree)
const previous = migrationUnsupportedByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map<string, MigrationUnsupportedPtyEntry[]>()
for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) {
for (const unsupported of Object.values(migrationUnsupportedByPtyId)) {
if (!unsupported.paneKey) {
continue
}
@ -148,8 +154,8 @@ function getMigrationUnsupportedByWorktree(
entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries))
}
migrationUnsupportedByWorktreeCache = {
tabsByWorktree: state.tabsByWorktree,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
tabsByWorktree,
migrationUnsupportedByPtyId,
entriesByWorktree
}
return entriesByWorktree
@ -158,13 +164,14 @@ function getMigrationUnsupportedByWorktree(
function getRetainedEntriesByWorktree(
state: WorktreeAgentRowsState
): Map<string, RetainedAgentEntry[]> {
if (retainedEntriesByWorktreeCache?.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey) {
const retainedAgentsByPaneKey = state.retainedAgentsByPaneKey ?? EMPTY_RECORD
if (retainedEntriesByWorktreeCache?.retainedAgentsByPaneKey === retainedAgentsByPaneKey) {
return retainedEntriesByWorktreeCache.entriesByWorktree
}
const previous = retainedEntriesByWorktreeCache?.entriesByWorktree
const entriesByWorktree = new Map<string, RetainedAgentEntry[]>()
for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
for (const retained of Object.values(retainedAgentsByPaneKey)) {
const bucket = entriesByWorktree.get(retained.worktreeId)
if (bucket) {
bucket.push(retained)
@ -176,7 +183,7 @@ function getRetainedEntriesByWorktree(
entriesByWorktree.set(worktreeId, reuseArrayIfEqual(previous?.get(worktreeId), entries))
}
retainedEntriesByWorktreeCache = {
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
retainedAgentsByPaneKey,
entriesByWorktree
}
return entriesByWorktree
@ -215,16 +222,20 @@ export function selectRuntimeAgentOrchestrationForWorktree(
>,
worktreeId: string
): Record<string, AgentStatusOrchestrationContext> {
const tabs = state.tabsByWorktree[worktreeId] ?? []
const tabs = (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []
const tabIds = new Set(tabs.map((tab) => tab.id))
const out: Record<string, AgentStatusOrchestrationContext> = {}
for (const [paneKey, orchestration] of Object.entries(state.runtimeAgentOrchestrationByPaneKey)) {
const runtimeAgentOrchestrationByPaneKey =
state.runtimeAgentOrchestrationByPaneKey ?? EMPTY_RECORD
const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_RECORD
const retainedAgentsByPaneKey = state.retainedAgentsByPaneKey ?? EMPTY_RECORD
for (const [paneKey, orchestration] of Object.entries(runtimeAgentOrchestrationByPaneKey)) {
const parsed = parsePaneKey(paneKey)
const parsedParent = orchestration.parentPaneKey
? parsePaneKey(orchestration.parentPaneKey)
: null
const liveEntry = state.agentStatusByPaneKey[paneKey]
const retainedEntry = state.retainedAgentsByPaneKey[paneKey]
const liveEntry = agentStatusByPaneKey[paneKey]
const retainedEntry = retainedAgentsByPaneKey[paneKey]
// Why: child agent terminals can be attributed to a worktree before their
// tab reaches this renderer, or after the row has been retained as done.
// The parent link must still reach that worktree card.
@ -245,8 +256,8 @@ export function selectTerminalLayoutsForWorktree(
worktreeId: string
): Record<string, TerminalLayoutSnapshot | undefined> {
const out: Record<string, TerminalLayoutSnapshot | undefined> = {}
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
out[tab.id] = state.terminalLayoutsByTabId[tab.id]
for (const tab of (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []) {
out[tab.id] = (state.terminalLayoutsByTabId ?? EMPTY_RECORD)[tab.id]
}
return out
}

View File

@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
import { getAgentDotState } from './worktree-card-agent-summary'
import { translate } from '@/i18n/i18n'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer'
function formatShortTimeAgo(ts: number, now: number): string {
const delta = now - ts
@ -92,6 +93,7 @@ type CompactAgentRowProps = {
reserveDisclosureGutter?: boolean
isFocusedPane?: boolean
hideIdentityIcon?: boolean
cacheTimerActive?: boolean
}
export const CompactAgentRow = React.memo(function CompactAgentRow({
@ -106,7 +108,8 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
onToggleChildAgents,
reserveDisclosureGutter = false,
isFocusedPane = false,
hideIdentityIcon = false
hideIdentityIcon = false,
cacheTimerActive = true
}: CompactAgentRowProps) {
const hasChildDisclosure =
typeof childAgentCount === 'number' &&
@ -117,6 +120,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
const isLineageChild = agent.lineage?.depth === 1
const secondary = getCompactAgentSecondary(agent)
const shortTime = getCompactAgentTime(agent, now)
const cacheTimer = usePromptCacheCountdownForPane(agent.paneKey, cacheTimerActive)
const handleActivate = useCallback(
(e: React.MouseEvent) => {
@ -213,6 +217,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
+{childAgentCount}
</span>
)}
{cacheTimer && <CacheTimer startedAt={cacheTimer.startedAt} ttlMs={cacheTimer.ttlMs} />}
{shortTime && (
<span
className={cn(