{/* Why: the non-workspace titlebar lives inside this left+center
diff --git a/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx b/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx
index ca0efecc6fc..cf9a231397c 100644
--- a/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx
+++ b/src/renderer/src/components/dashboard/RetainedAgentsSyncGate.tsx
@@ -1,15 +1,11 @@
-import { useDashboardData } from './useDashboardData'
import { useRetainedAgentsSync } from './useRetainedAgents'
// Why: isolate the retention subscriptions in a leaf component that renders
-// null, so the high-churn slices read by useDashboardData
-// (agentStatusByPaneKey + agentStatusEpoch, which tick at PTY event frequency)
-// do not re-render the entire App tree. Retention must still run at the App
-// level — if it only ran when a single card was mounted, "done" agents would
-// vanish from the inline agents list any time the user scrolled that card
-// out of view.
+// null, so agent-status retention work does not re-render the entire App tree.
+// Retention must still run at the App level — if it only ran when a single
+// card was mounted, "done" agents would vanish from the inline agents list any
+// time the user scrolled that card out of view.
export default function RetainedAgentsSyncGate(): null {
- const dashboardLiveGroups = useDashboardData()
- useRetainedAgentsSync(dashboardLiveGroups)
+ useRetainedAgentsSync()
return null
}
diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts
index fb62d30edfb..1385f8a9d05 100644
--- a/src/renderer/src/components/dashboard/useDashboardData.ts
+++ b/src/renderer/src/components/dashboard/useDashboardData.ts
@@ -23,10 +23,9 @@ export type DashboardAgentRow = {
startedAt: number
}
-// Why: the shape here is deliberately minimal — just what useRetainedAgentsSync
-// needs to diff liveGroups and decide which vanished agents to retain. The
-// per-card rendering pipeline is separate (WorktreeCardAgents +
-// useWorktreeAgentRows read retained entries directly from the store).
+// Why: the shape here is deliberately minimal. The per-card rendering pipeline
+// is separate (WorktreeCardAgents + useWorktreeAgentRows read retained entries
+// directly from the store).
export type DashboardWorktreeCard = {
repo: Repo
worktree: Worktree
@@ -134,10 +133,7 @@ function buildDashboardData(
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
- * Cross-worktree aggregate of live agent rows. Used by useRetainedAgentsSync
- * to drive retention: when a previously-live 'done' agent disappears from
- * this set, its snapshot is moved into retainedAgentsByPaneKey so the inline
- * per-card list can still render it.
+ * Cross-worktree aggregate of live agent rows.
*
* Not used to render anything directly — the inline list reads its own
* worktree-scoped slice via useWorktreeAgentRows.
diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts
index 64f2864d5d4..cfab009a172 100644
--- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts
+++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts
@@ -1,12 +1,8 @@
import { describe, expect, it } from 'vitest'
-import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
import { collectRetainedAgentsOnDisappear } from './useRetainedAgents'
-function makeAgentRow(args: {
- paneKey: string
- state: 'working' | 'blocked' | 'waiting' | 'done'
- interrupted?: boolean
-}) {
+function makeAgentRow(args: { paneKey: string; state: AgentStatusState; interrupted?: boolean }) {
const entry: AgentStatusEntry = {
state: args.state,
prompt: 'Fix it',
diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts
index b8c11460b70..bb3d0f8271c 100644
--- a/src/renderer/src/components/dashboard/useRetainedAgents.ts
+++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts
@@ -1,47 +1,187 @@
import { useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
-import { type DashboardRepoGroup, type DashboardAgentRow } from './useDashboardData'
+import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
+import { type DashboardAgentRow } from './useDashboardData'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
+import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
+import {
+ AGENT_STATUS_STALE_AFTER_MS,
+ type AgentStatusEntry
+} from '../../../../shared/agent-status-types'
// Why: when an agent finishes or its terminal closes, the store cleans up the
-// explicit status entry and the agent vanishes from useDashboardData. Retaining
-// the last-known "done" snapshot in the store lets the inline per-card agents
-// list render the done row until the user dismisses it, rather than having the
-// row wink out the moment the terminal process exits.
+// explicit status entry and the agent vanishes from the live status set.
+// Retaining the last-known "done" snapshot in the store lets the inline
+// per-card agents list render the done row until the user dismisses it, rather
+// than having the row wink out the moment the terminal process exits.
-export function useRetainedAgentsSync(liveGroups: DashboardRepoGroup[]): void {
+type RetainedAgentSnapshot = Map
+
+type RetainedAgentsSyncInputs = {
+ repos: Repo[]
+ worktreesByRepo: Record
+ tabsByWorktree: Record
+ agentStatusByPaneKey: Record
+ agentStatusEpoch?: number
+}
+
+type RetainedAgentsSyncSnapshotInputs = RetainedAgentsSyncInputs & {
+ now: number
+}
+
+function paneKeyTabId(paneKey: string): string | null {
+ const colonIndex = paneKey.indexOf(':')
+ if (colonIndex <= 0) {
+ return null
+ }
+ return paneKey.slice(0, colonIndex)
+}
+
+function buildLiveTabIndex(args: {
+ repos: Repo[]
+ worktreesByRepo: Record
+ tabsByWorktree: Record
+}): {
+ existingWorktreeIds: Set
+ tabIndex: Map
+} {
+ const existingWorktreeIds = new Set()
+ const tabIndex = new Map()
+
+ for (const repo of args.repos) {
+ const worktrees = args.worktreesByRepo[repo.id] ?? []
+ for (const worktree of worktrees) {
+ if (worktree.isArchived) {
+ continue
+ }
+ existingWorktreeIds.add(worktree.id)
+ const tabs = args.tabsByWorktree[worktree.id] ?? []
+ for (const tab of tabs) {
+ tabIndex.set(tab.id, { tab, worktreeId: worktree.id })
+ }
+ }
+ }
+
+ return { existingWorktreeIds, tabIndex }
+}
+
+function agentStartedAt(entry: AgentStatusEntry): number {
+ return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
+}
+
+export function buildRetainedAgentsSyncSignature(args: RetainedAgentsSyncInputs): string {
+ const { existingWorktreeIds, tabIndex } = buildLiveTabIndex(args)
+ const worktreeParts = [...existingWorktreeIds].sort()
+ const tabParts = [...tabIndex.entries()]
+ .map(([tabId, owner]) => `${owner.worktreeId}:${tabId}`)
+ .sort()
+ const agentParts: string[] = []
+
+ for (const [paneKey, entry] of Object.entries(args.agentStatusByPaneKey)) {
+ const tabId = paneKeyTabId(paneKey)
+ if (!tabId) {
+ continue
+ }
+ const owner = tabIndex.get(tabId)
+ if (!owner) {
+ continue
+ }
+ // Why: working/blocked/waiting pings can update prompt/tool text dozens of
+ // times per second; retention only cares about identity, state, freshness,
+ // and final done payloads.
+ const doneUpdatedAt = entry.state === 'done' ? entry.updatedAt : ''
+ agentParts.push(
+ [
+ owner.worktreeId,
+ paneKey,
+ entry.state,
+ entry.interrupted === true ? 'interrupted' : '',
+ agentStartedAt(entry),
+ doneUpdatedAt
+ ].join(':')
+ )
+ }
+
+ agentParts.sort()
+ return [
+ `epoch:${args.agentStatusEpoch ?? 0}`,
+ `worktrees:${worktreeParts.join(',')}`,
+ `tabs:${tabParts.join(',')}`,
+ `agents:${agentParts.join(',')}`
+ ].join('|')
+}
+
+export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshotInputs): {
+ currentAgents: RetainedAgentSnapshot
+ existingWorktreeIds: Set
+} {
+ const { existingWorktreeIds, tabIndex } = buildLiveTabIndex(args)
+ const currentAgents: RetainedAgentSnapshot = new Map()
+
+ for (const [paneKey, entry] of Object.entries(args.agentStatusByPaneKey)) {
+ const tabId = paneKeyTabId(paneKey)
+ if (!tabId) {
+ continue
+ }
+ const owner = tabIndex.get(tabId)
+ if (!owner) {
+ continue
+ }
+ const isFresh = isExplicitAgentStatusFresh(entry, args.now, AGENT_STATUS_STALE_AFTER_MS)
+ const shouldDecay =
+ !isFresh &&
+ (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
+ currentAgents.set(paneKey, {
+ row: {
+ paneKey,
+ entry,
+ tab: owner.tab,
+ agentType: entry.agentType ?? 'unknown',
+ state: shouldDecay ? 'idle' : entry.state,
+ startedAt: agentStartedAt(entry)
+ },
+ worktreeId: owner.worktreeId
+ })
+ }
+
+ return { currentAgents, existingWorktreeIds }
+}
+
+export function useRetainedAgentsSync(): void {
const retainAgents = useAppStore((s) => s.retainAgents)
const pruneRetainedAgents = useAppStore((s) => s.pruneRetainedAgents)
const clearRetentionSuppressedPaneKeys = useAppStore((s) => s.clearRetentionSuppressedPaneKeys)
- const prevAgentsRef = useRef