Show prompt cache timers on compact agent rows (#6295)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
3f775c34c1
commit
23cde7bef0
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -137,9 +137,5 @@ export function useGitStatusFileWatchRefresh({
|
|||
}
|
||||
window.removeEventListener(ORCA_WORKTREE_FILE_CHANGE_EVENT, handleFsChanged as EventListener)
|
||||
}
|
||||
}, [
|
||||
activeRuntimeEnvironmentId,
|
||||
shouldSubscribe,
|
||||
worktreePath
|
||||
])
|
||||
}, [activeRuntimeEnvironmentId, shouldSubscribe, worktreePath])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue