diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index f45a4e56c..9c42f87ee 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -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 }) diff --git a/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts index a0f8d6a5d..034cc9e57 100644 --- a/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts @@ -137,9 +137,5 @@ export function useGitStatusFileWatchRefresh({ } window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleFsChanged as EventListener) } - }, [ - activeRuntimeEnvironmentId, - shouldSubscribe, - worktreePath - ]) + }, [activeRuntimeEnvironmentId, shouldSubscribe, worktreePath]) } diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 5d374e1b7..e357069b0 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -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( diff --git a/src/renderer/src/components/sidebar/CacheTimer.test.tsx b/src/renderer/src/components/sidebar/CacheTimer.test.tsx new file mode 100644 index 000000000..651de8e0b --- /dev/null +++ b/src/renderer/src/components/sidebar/CacheTimer.test.tsx @@ -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 + settings?: { + promptCacheTimerEnabled?: boolean + promptCacheTtlMs?: number + } + tabsByWorktree: Record +} + +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 {startedAt ?? 'none'} +} + +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() + + 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() + + 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() + + expect(markup).toContain('none') + expect(scanMocks.getMostUrgentPromptCacheStartedAt).not.toHaveBeenCalled() + }) + + it('scans aggregate cache timers only when the timer can render', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('1000') + expect(scanMocks.getMostUrgentPromptCacheStartedAt).toHaveBeenCalledTimes(1) + expect(scanMocks.getMostUrgentPromptCacheStartedAt).toHaveBeenCalledWith( + mockState.tabsByWorktree['wt-1'], + mockState.cacheTimerByKey + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/CacheTimer.tsx b/src/renderer/src/components/sidebar/CacheTimer.tsx index 8be4cb07d..9932d97a8 100644 --- a/src/renderer/src/components/sidebar/CacheTimer.tsx +++ b/src/renderer/src/components/sidebar/CacheTimer.tsx @@ -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. diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index b37e5e671..d46fc5172 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -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 = {} let projectGroups: unknown[] = [] let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null let settings: Partial | 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 }) => ( -
+ default: ({ className, agents }: { className?: string; agents?: DashboardAgentRowData[] }) => ( +
) })) @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + 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'] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 386ee7dcd..07a0b887f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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 && ( )} diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index dd31a6cdc..f3bd592f3 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -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 = {} 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('Focused prompt') }) + 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() + const timerIndex = markup.indexOf('Prompt cache expires in 1:00') + const ageIndex = markup.indexOf('>now') + + 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() + + 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() + + 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( + + ) + + 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() + + 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 = [ diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 431dc8ca3..b6a0f9f88 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -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 = new Set() + ancestorPaneKeys: ReadonlySet = 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 ? (
{childAgents.map((childAgent) => - renderCompactAgentBranch(childAgent, descendantAncestorPaneKeys) + renderCompactAgentBranch( + childAgent, + descendantAncestorPaneKeys, + cacheTimerActive && expanded + ) )}
@@ -444,7 +453,9 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ }} /> - {rootAgents.map((rootAgent) => renderCompactAgentBranch(rootAgent))} + {rootAgents.map((rootAgent) => + renderCompactAgentBranch(rootAgent, new Set(), compactRootListExpanded) + )}
) : ( diff --git a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts index a5aba378a..03b82a45f 100644 --- a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts +++ b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts index 87c63da1a..69232f64f 100644 --- a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts +++ b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts @@ -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, + ttlMs: number +): PromptCacheCountdownSelection | null { + if (ttlMs <= 0 || parsePaneKey(paneKey) === null) { + return null + } + const startedAt = cacheTimerByKey[paneKey] + return startedAt == null ? null : { startedAt, ttlMs } +} diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index be3ab7fd4..9cfc00e32 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -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(() => { + 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, diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 546618af1..8fe9290ba 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -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 = {} +// 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 { + 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() - 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 { + 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() - 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 { - 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() - 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 { - const tabs = state.tabsByWorktree[worktreeId] ?? [] + const tabs = (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? [] const tabIds = new Set(tabs.map((tab) => tab.id)) const out: Record = {} - 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 { const out: Record = {} - 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 } diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 5f7a0b6ad..5e75a7f3d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -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} )} + {cacheTimer && } {shortTime && (