diff --git a/src/renderer/src/workspace/dispatch/DispatchLayout.tsx b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx
index 10a9e1c56..4c9375dc4 100644
--- a/src/renderer/src/workspace/dispatch/DispatchLayout.tsx
+++ b/src/renderer/src/workspace/dispatch/DispatchLayout.tsx
@@ -207,6 +207,14 @@ const DispatchAgentListRow = memo(function DispatchAgentListRow({
workContext: current?.workContext,
workActivity: current?.workActivity,
entries: current?.entries,
+ unreadSince: current?.unreadSince,
+ unreadKind: current?.unreadKind,
+ pendingApproval: current?.pendingApproval,
+ pendingTrustDialog: current?.pendingTrustDialog,
+ pendingResumePrompt: current?.pendingResumePrompt,
+ pendingPermissionPrompt: current?.pendingPermissionPrompt,
+ pendingCompaction: current?.pendingCompaction,
+ processError: current?.processError,
}
}))
const onSelect = useCallback(() => {
@@ -218,6 +226,12 @@ const DispatchAgentListRow = memo(function DispatchAgentListRow({
const title = runtime.entries
? cachedLatestPromptTitle(runtime.entries, row.kind) ?? row.title
: row.title
+ const attentionLabel = dispatchAttentionLabel(runtime)
+ const unreadKind = attentionLabel
+ ? 'attention'
+ : runtime.unreadKind === 'attention'
+ ? 'output'
+ : runtime.unreadKind
return (
)}
+ {unreadKind && (
+
+ )}
{subtitle}
@@ -265,6 +282,7 @@ function dispatchSubtitle(runtime: {
sessionStatus?: string
streamPhase?: string
exited?: number | null
+ unreadSince?: number | null
}): string {
if (runtime.sessionStatus === undefined) return 'starting'
if (runtime.streamPhase && runtime.streamPhase !== 'idle') return runtime.streamPhase
@@ -273,6 +291,56 @@ function dispatchSubtitle(runtime: {
return 'idle'
}
+function dispatchAttentionLabel(runtime: {
+ pendingApproval?: unknown
+ pendingTrustDialog?: unknown
+ pendingResumePrompt?: unknown
+ pendingPermissionPrompt?: unknown
+ pendingCompaction?: { phase?: string } | null
+ processError?: string | null
+}): string | null {
+ if (runtime.pendingPermissionPrompt) return 'ACTION'
+ if (runtime.pendingApproval) return 'ACTION'
+ if (runtime.pendingTrustDialog) return 'TRUST'
+ if (runtime.pendingResumePrompt) return 'RESUME'
+ if (runtime.pendingCompaction?.phase === 'error') return 'ERROR'
+ if (runtime.processError) return 'ERROR'
+ return null
+}
+
+function DispatchUnreadBadge({
+ kind,
+ label,
+}: {
+ kind: 'output' | 'attention'
+ label: string | null
+}) {
+ if (kind === 'attention') {
+ return (
+
+ {label ?? 'ACTION'}
+
+ )
+ }
+ return (
+
+ NEW
+
+ )
+}
+
function dispatchActivity(runtime: {
sessionStatus?: string
streamPhase?: string
diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts
index 34f02b686..f492acd9f 100644
--- a/src/renderer/src/workspace/hook/actions/pane.ts
+++ b/src/renderer/src/workspace/hook/actions/pane.ts
@@ -690,13 +690,21 @@ export function usePaneActions(
t.id === prev.activeTabId ? { ...t, focusedSessionId: sessionId } : t,
),
}))
+ setRuntimes(prev => {
+ const current = prev[sessionId]
+ if (!current || (current.unreadSince === null && current.unreadKind === null)) return prev
+ return {
+ ...prev,
+ [sessionId]: { ...current, unreadSince: null, unreadKind: null },
+ }
+ })
setSpotlight(prev => (
prev && prev.tabId === refs.stateRef.current.activeTabId
? { ...prev, focusedSessionId: sessionId }
: prev
))
},
- [refs.stateRef, setSpotlight, setState],
+ [refs.stateRef, setRuntimes, setSpotlight, setState],
)
const focusSessionInTab = useCallback(
@@ -708,6 +716,14 @@ export function usePaneActions(
t.id === tabId ? { ...t, focusedSessionId: sessionId } : t,
),
}))
+ setRuntimes(prev => {
+ const current = prev[sessionId]
+ if (!current || (current.unreadSince === null && current.unreadKind === null)) return prev
+ return {
+ ...prev,
+ [sessionId]: { ...current, unreadSince: null, unreadKind: null },
+ }
+ })
setSpotlight(prev => (
prev && prev.tabId === tabId
? { ...prev, focusedSessionId: sessionId }
@@ -719,7 +735,7 @@ export function usePaneActions(
: prev
))
},
- [setSpotlight, setState, setTileTabs],
+ [setRuntimes, setSpotlight, setState, setTileTabs],
)
const navigate = useCallback(
diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts
index 47c978ab2..ba236cb79 100644
--- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts
+++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts
@@ -243,6 +243,33 @@ export function useIpcSubscriptions(
})
}
+ const isFocusedSession = (sessionId: SessionId): boolean => {
+ const snap = refs.stateRef.current
+ const activeTab = snap.tabs.find(tab => tab.id === snap.activeTabId)
+ return activeTab?.focusedSessionId === sessionId
+ }
+
+ const withUnread = (
+ sessionId: SessionId,
+ runtime: SessionRuntime,
+ kind: 'output' | 'attention',
+ ): SessionRuntime => {
+ if (isFocusedSession(sessionId)) return runtime
+ // Attention outranks ordinary output: once a permission/trust
+ // prompt appears, the list should keep showing ACTION until
+ // the user opens that agent or the prompt resolves. A later
+ // transcript append must not downgrade the marker to NEW.
+ const unreadKind =
+ runtime.unreadKind === 'attention' || kind === 'attention'
+ ? 'attention'
+ : 'output'
+ return {
+ ...runtime,
+ unreadSince: runtime.unreadSince ?? Date.now(),
+ unreadKind,
+ }
+ }
+
const offStarted = window.api.onSessionStarted(({ sessionId, projectDir }) => {
updateRuntime(sessionId, {
projectDir,
@@ -392,10 +419,14 @@ export function useIpcSubscriptions(
// writer.
pendingApproval: nextApproval,
}
+ const approvalOpened = current.pendingApproval === null && nextApproval !== null
+ const nextBodyWithUnread = approvalOpened
+ ? withUnread(sessionId, nextBody, 'attention')
+ : nextBody
const nextCurrent = chromeTickOnly
- ? nextBody
+ ? nextBodyWithUnread
: appendFeedDebugLog(
- nextBody,
+ nextBodyWithUnread,
{
layer: 'STATE',
kind: 'screen_update',
@@ -756,8 +787,16 @@ export function useIpcSubscriptions(
const offTrustDialog = window.api.onSessionTrustDialog(
({ sessionId, visible, workspace }) => {
- updateRuntime(sessionId, {
- pendingTrustDialog: visible ? { workspace } : null,
+ setRuntimes(prev => {
+ const current = prev[sessionId] ?? emptyRuntime()
+ const next = {
+ ...current,
+ pendingTrustDialog: visible ? { workspace } : null,
+ }
+ return {
+ ...prev,
+ [sessionId]: visible ? withUnread(sessionId, next, 'attention') : next,
+ }
})
},
)
@@ -770,10 +809,18 @@ export function useIpcSubscriptions(
options,
selectedIndex,
}) => {
- updateRuntime(sessionId, {
- pendingResumePrompt: visible
- ? { sessionAgeText, tokenCountText, options, selectedIndex }
- : null,
+ setRuntimes(prev => {
+ const current = prev[sessionId] ?? emptyRuntime()
+ const next = {
+ ...current,
+ pendingResumePrompt: visible
+ ? { sessionAgeText, tokenCountText, options, selectedIndex }
+ : null,
+ }
+ return {
+ ...prev,
+ [sessionId]: visible ? withUnread(sessionId, next, 'attention') : next,
+ }
})
})
@@ -786,10 +833,18 @@ export function useIpcSubscriptions(
options,
selectedIndex,
}) => {
- updateRuntime(sessionId, {
- pendingPermissionPrompt: visible
- ? { title, toolName, command, options, selectedIndex }
- : null,
+ setRuntimes(prev => {
+ const current = prev[sessionId] ?? emptyRuntime()
+ const next = {
+ ...current,
+ pendingPermissionPrompt: visible
+ ? { title, toolName, command, options, selectedIndex }
+ : null,
+ }
+ return {
+ ...prev,
+ [sessionId]: visible ? withUnread(sessionId, next, 'attention') : next,
+ }
})
})
@@ -800,10 +855,20 @@ export function useIpcSubscriptions(
statusText,
errorText,
}) => {
- updateRuntime(sessionId, {
- pendingCompaction: visible && phase
- ? { phase, statusText, errorText }
- : null,
+ setRuntimes(prev => {
+ const current = prev[sessionId] ?? emptyRuntime()
+ const next = {
+ ...current,
+ pendingCompaction: visible && phase
+ ? { phase, statusText, errorText }
+ : null,
+ }
+ return {
+ ...prev,
+ [sessionId]: visible && phase === 'error'
+ ? withUnread(sessionId, next, 'attention')
+ : next,
+ }
})
})
@@ -895,6 +960,7 @@ export function useIpcSubscriptions(
let awaitingAssistant = current.awaitingAssistant
let workActivity = current.workActivity
let workContext = current.workContext
+ let approvalOpened = false
const cachedWorktrees = sessionCwd ? worktreeCache.get(sessionCwd) : undefined
const hasWorktreeCache = !!cachedWorktrees && cachedWorktrees.refreshedAt > 0
const worktrees = cachedWorktrees?.worktrees ?? []
@@ -948,6 +1014,7 @@ export function useIpcSubscriptions(
// Codex exec_approval_request opens an approval pane;
// exec_command_end with the same call_id closes it.
if (raw.type === 'event_msg' && payload?.type === 'exec_approval_request') {
+ approvalOpened = pendingApproval === null
pendingApproval = {
callId: typeof payload.call_id === 'string' ? payload.call_id : null,
command: Array.isArray(payload.command)
@@ -1174,7 +1241,8 @@ export function useIpcSubscriptions(
return prev
}
- const nextRuntime = withDerivedSessionStatus(
+ const hasLiveOutput = appended.length > 0 && current.transcriptStatus === 'ready'
+ const nextRuntimeBase = withDerivedSessionStatus(
appendFeedDebugLog(
{
...current,
@@ -1220,6 +1288,11 @@ export function useIpcSubscriptions(
},
),
)
+ const nextRuntime = approvalOpened
+ ? withUnread(sessionId, nextRuntimeBase, 'attention')
+ : hasLiveOutput
+ ? withUnread(sessionId, nextRuntimeBase, 'output')
+ : nextRuntimeBase
closeSpan({
sessionId,
burstSize: entries.length,
diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/SessionBadges.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/SessionBadges.tsx
index 9ca4152c5..da12e7cf5 100644
--- a/src/renderer/src/workspace/tile-tree/TileLeaf/SessionBadges.tsx
+++ b/src/renderer/src/workspace/tile-tree/TileLeaf/SessionBadges.tsx
@@ -13,19 +13,28 @@ export function WorktreeBadge({
context: AgentWorkContext | null | undefined
activity: WorktreeActivityState | null | undefined
}) {
- if (!context?.worktreePath) return null
- const label = context.branch ?? shortPath(context.worktreePath)
+ // The badge is a "where is this agent working now?" signal, so
+ // prefer the latest active worktree over the longer-lived primary
+ // score winner. The primary context can lag badly in sessions that
+ // start on main and later move to a feature worktree: main has more
+ // cumulative events, while activity.active already reflects the
+ // most recent command cwd.
+ const displayContext = activity?.active?.worktreePath
+ ? activity.active
+ : context
+ if (!displayContext?.worktreePath) return null
+ const label = displayContext.branch ?? shortPath(displayContext.worktreePath)
if (!label) return null
- const color = worktreeBadgeColor(context)
+ const color = worktreeBadgeColor(displayContext)
const title = [
- 'Primary worktree',
- context.branch ? `Branch: ${context.branch}` : null,
- `Worktree: ${context.worktreePath}`,
- `Source: ${context.source}`,
- `Confidence: ${context.confidence}`,
- activity?.active?.worktreePath &&
- activity.active.worktreePath !== context.worktreePath
- ? `Active now: ${activity.active.branch ?? shortPath(activity.active.worktreePath)} (${activity.active.worktreePath})`
+ activity?.active?.worktreePath ? 'Active worktree' : 'Primary worktree',
+ displayContext.branch ? `Branch: ${displayContext.branch}` : null,
+ `Worktree: ${displayContext.worktreePath}`,
+ `Source: ${displayContext.source}`,
+ `Confidence: ${displayContext.confidence}`,
+ context?.worktreePath &&
+ context.worktreePath !== displayContext.worktreePath
+ ? `Primary: ${context.branch ?? shortPath(context.worktreePath)} (${context.worktreePath})`
: null,
activity
? `Touched: ${Object.values(activity.touched).length}`
diff --git a/src/renderer/src/workspace/workspaceState.ts b/src/renderer/src/workspace/workspaceState.ts
index d2b1a0464..be6d28f96 100644
--- a/src/renderer/src/workspace/workspaceState.ts
+++ b/src/renderer/src/workspace/workspaceState.ts
@@ -261,6 +261,17 @@ export type SessionRuntime = {
draftInput: string
draftImages: ClaudeDraftImage[]
activityStatus: string | null
+ /** Unread marker for list surfaces such as Dispatch Mode.
+ *
+ * WHY this lives on the runtime instead of being derived from
+ * entry counts: entries arrive from several sources (semantic
+ * ghosts, JSONL replay, optimistic rows), and replaying history
+ * would make count-based badges lie. The UI only needs a durable
+ * "something happened while you were elsewhere" bit. Focus
+ * actions clear it; IPC writers set it when hidden sessions
+ * receive user-visible output or action-required prompts. */
+ unreadSince: number | null
+ unreadKind: 'output' | 'attention' | null
paneToast: string | null
pendingApproval: {
callId: string | null
@@ -427,6 +438,8 @@ export function emptyRuntime(): SessionRuntime {
draftInput: '',
draftImages: [],
activityStatus: null,
+ unreadSince: null,
+ unreadKind: null,
paneToast: null,
pendingApproval: null,
pendingTrustDialog: null,