- {cardProps.includes('status') &&
}
+ {cardProps.includes('status') && (
+
+
+
+
+
+ )}
{cardProps.includes('unread') && (
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx
index 96010bc85f8..f4e7f93e308 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -430,6 +430,7 @@ const WorktreeList = React.memo(function WorktreeList() {
const issueCache = useAppStore((s) => (searchQuery ? s.issueCache : null))
const sortEpoch = useAppStore((s) => s.sortEpoch)
+ const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
// Count of non-archived worktrees — used to detect structural changes
// (add/remove) vs. pure reorders (score shifts) so the debounce below
@@ -472,7 +473,7 @@ const WorktreeList = React.memo(function WorktreeList() {
const timer = setTimeout(() => setDebouncedSortEpoch(sortEpoch), SORT_SETTLE_MS)
return () => clearTimeout(timer)
- }, [sortEpoch, debouncedSortEpoch, worktreeCount])
+ }, [sortEpoch, debouncedSortEpoch, worktreeCount, agentStatusEpoch])
// Why a latching ref: we need to distinguish "app just started, no PTYs
// have spawned yet" from "user closed all terminals mid-session." The
@@ -522,14 +523,22 @@ const WorktreeList = React.memo(function WorktreeList() {
const currentTabs = state.tabsByWorktree
nonArchivedWorktrees.sort(
- buildWorktreeComparator(sortBy, currentTabs, repoMap, state.prCache, Date.now())
+ buildWorktreeComparator(
+ sortBy,
+ currentTabs,
+ repoMap,
+ state.prCache,
+ Date.now(),
+ null,
+ state.agentStatusByPaneKey
+ )
)
return nonArchivedWorktrees.map((w) => w.id)
// debouncedSortEpoch is an intentional trigger: it's not read inside the
// memo, but its change signals that the sort order should be recomputed.
// The debounce prevents jarring mid-interaction position shifts.
// oxlint-disable-next-line react-hooks/exhaustive-deps
- }, [debouncedSortEpoch, repoMap, sortBy])
+ }, [debouncedSortEpoch, repoMap, sortBy, agentStatusEpoch])
// Persist the computed sort order so the sidebar can be restored after
// restart. Only persist during live sessions (sessionHasHadPty latched) —
diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts
index 502090fc5ed..e641da6447c 100644
--- a/src/renderer/src/components/sidebar/smart-sort.test.ts
+++ b/src/renderer/src/components/sidebar/smart-sort.test.ts
@@ -2,6 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import { buildWorktreeComparator, computeSmartScore, type SmartSortOverride } from './smart-sort'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
@@ -53,6 +54,20 @@ function makeTab(overrides: Partial = {}): TerminalTab {
}
}
+function makeAgentStatusEntry(
+ overrides: Partial & { paneKey: string }
+): AgentStatusEntry {
+ return {
+ state: overrides.state ?? 'working',
+ prompt: overrides.prompt ?? '',
+ updatedAt: overrides.updatedAt ?? NOW - 30_000,
+ agentType: overrides.agentType ?? 'codex',
+ paneKey: overrides.paneKey,
+ terminalTitle: overrides.terminalTitle,
+ stateHistory: overrides.stateHistory ?? []
+ }
+}
+
describe('computeSmartScore', () => {
afterEach(() => {
vi.restoreAllMocks()
@@ -158,6 +173,39 @@ describe('computeSmartScore', () => {
computeSmartScore(plain, null, repoMap, {})
)
})
+
+ it('does not let stale explicit status mask a live heuristic permission prompt', () => {
+ const worktree = makeWorktree({ id: 'wt-1' })
+ const tabsByWorktree = {
+ [worktree.id]: [makeTab({ worktreeId: worktree.id, title: 'codex permission needed' })]
+ }
+ const score = computeSmartScore(worktree, tabsByWorktree, repoMap, null, NOW, {
+ 'tab-1:1': makeAgentStatusEntry({
+ paneKey: 'tab-1:1',
+ state: 'done',
+ updatedAt: NOW - 45 * 60_000
+ })
+ })
+
+ expect(score).toBeGreaterThanOrEqual(35)
+ })
+
+ it('does not stack heuristic working on top of fresh explicit done for the same tab', () => {
+ const worktree = makeWorktree({ id: 'wt-1' })
+ const tabsByWorktree = {
+ [worktree.id]: [makeTab({ worktreeId: worktree.id, title: 'codex working' })]
+ }
+
+ expect(
+ computeSmartScore(worktree, tabsByWorktree, repoMap, null, NOW, {
+ 'tab-1:1': makeAgentStatusEntry({
+ paneKey: 'tab-1:1',
+ state: 'done',
+ updatedAt: NOW - 60_000
+ })
+ })
+ ).toBe(12)
+ })
})
describe('buildWorktreeComparator', () => {
diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts
index 6e8e23af4ed..3fa94654784 100644
--- a/src/renderer/src/components/sidebar/smart-sort.ts
+++ b/src/renderer/src/components/sidebar/smart-sort.ts
@@ -1,6 +1,10 @@
-import { detectAgentStatusFromTitle } from '@/lib/agent-status'
+import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { branchName } from '@/lib/git-utils'
import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
+import {
+ AGENT_STATUS_STALE_AFTER_MS,
+ type AgentStatusEntry
+} from '../../../../shared/agent-status-types'
type SortBy = 'name' | 'smart' | 'recent' | 'repo'
@@ -35,18 +39,72 @@ function computeSmartScoreFromSignals(
worktree: Worktree,
tabs: TerminalTab[],
hasRecentPR: boolean,
- now: number
+ now: number,
+ agentStatusByPaneKey?: Record
): number {
const liveTabs = tabs.filter((t) => t.ptyId)
let score = 0
- const isRunning = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'working')
+ // Why: explicit agent status (OSC 9999) is authoritative over heuristic title
+ // parsing. Check explicit status first; fall through to heuristics for tabs
+ // that have no explicit status entry.
+ const explicitEntries = agentStatusByPaneKey
+ ? Object.values(agentStatusByPaneKey).filter((e) =>
+ tabs.some((t) => e.paneKey.startsWith(`${t.id}:`))
+ )
+ : []
+ const explicitByTabId = new Map()
+ for (const entry of explicitEntries) {
+ const colonIndex = entry.paneKey.indexOf(':')
+ const tabId = colonIndex === -1 ? entry.paneKey : entry.paneKey.slice(0, colonIndex)
+ const existing = explicitByTabId.get(tabId)
+ if (existing) {
+ existing.push(entry)
+ } else {
+ explicitByTabId.set(tabId, [entry])
+ }
+ }
+
+ let hasExplicitWorking = false
+ let hasExplicitBlocked = false
+ let hasHeuristicWorking = false
+ let hasHeuristicBlocked = false
+
+ for (const tab of liveTabs) {
+ const tabExplicitEntries = explicitByTabId.get(tab.id) ?? []
+ const hasFreshExplicit = tabExplicitEntries.some((entry) =>
+ isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
+ )
+
+ if (hasFreshExplicit) {
+ hasExplicitWorking ||= tabExplicitEntries.some(
+ (entry) =>
+ isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) &&
+ entry.state === 'working'
+ )
+ hasExplicitBlocked ||= tabExplicitEntries.some(
+ (entry) =>
+ isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) &&
+ (entry.state === 'blocked' || entry.state === 'waiting')
+ )
+ continue
+ }
+
+ const heuristicState = detectAgentStatusFromTitle(tab.title)
+ hasHeuristicWorking ||= heuristicState === 'working'
+ hasHeuristicBlocked ||= heuristicState === 'permission'
+ }
+
+ // Explicit working → +60, same weight as heuristic working
+ // Explicit blocked/waiting → +35, same weight as heuristic permission
+ // Explicit done → no bonus (task complete, no attention needed)
+ const isRunning = hasExplicitWorking || hasHeuristicWorking
if (isRunning) {
score += 60
}
- const needsAttention = liveTabs.some((t) => detectAgentStatusFromTitle(t.title) === 'permission')
+ const needsAttention = hasExplicitBlocked || hasHeuristicBlocked
if (needsAttention) {
score += 35
}
@@ -106,7 +164,8 @@ export function buildWorktreeComparator(
repoMap: Map,
prCache: Record | null,
now: number = Date.now(),
- smartSortOverrides: Record | null = null
+ smartSortOverrides: Record | null = null,
+ agentStatusByPaneKey?: Record
): (a: Worktree, b: Worktree) => number {
return (a, b) => {
switch (sortBy) {
@@ -132,13 +191,15 @@ export function buildWorktreeComparator(
smartB.worktree,
smartB.tabs,
smartB.hasRecentPRSignal,
- now
+ now,
+ agentStatusByPaneKey
) -
computeSmartScoreFromSignals(
smartA.worktree,
smartA.tabs,
smartA.hasRecentPRSignal,
- now
+ now,
+ agentStatusByPaneKey
) ||
smartB.worktree.lastActivityAt - smartA.worktree.lastActivityAt ||
a.displayName.localeCompare(b.displayName)
@@ -179,7 +240,8 @@ export function sortWorktreesSmart(
worktrees: Worktree[],
tabsByWorktree: Record,
repoMap: Map,
- prCache: Record | null
+ prCache: Record | null,
+ agentStatusByPaneKey?: Record
): Worktree[] {
const hasAnyLivePty = Object.values(tabsByWorktree)
.flat()
@@ -192,8 +254,18 @@ export function sortWorktreesSmart(
)
}
+ // Why: agentStatusByPaneKey is forwarded so the smart-score comparator can
+ // use explicit agent status (OSC 9999) when ranking worktrees by recency.
return [...worktrees].sort(
- buildWorktreeComparator('smart', tabsByWorktree, repoMap, prCache, Date.now())
+ buildWorktreeComparator(
+ 'smart',
+ tabsByWorktree,
+ repoMap,
+ prCache,
+ Date.now(),
+ null,
+ agentStatusByPaneKey
+ )
)
}
@@ -215,7 +287,8 @@ export function computeSmartScore(
tabsByWorktree: Record | null,
repoMap: Map | null,
prCache: Record | null,
- now: number = Date.now()
+ now: number = Date.now(),
+ agentStatusByPaneKey?: Record
): number {
return computeSmartScoreFromSignals(
worktree,
@@ -225,6 +298,7 @@ export function computeSmartScore(
// only while that branch cache entry is still cold so smart sorting stays
// stable on launch without reviving stale PRs after a cache miss resolves.
repoMap ? hasRecentPRSignal(worktree, repoMap, prCache) : worktree.linkedPR !== null,
- now
+ now,
+ agentStatusByPaneKey
)
}
diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts
index 702d5f3dc7f..a887d06b927 100644
--- a/src/renderer/src/components/sidebar/visible-worktrees.ts
+++ b/src/renderer/src/components/sidebar/visible-worktrees.ts
@@ -120,9 +120,13 @@ export function getVisibleWorktreeIds(): string[] {
let sortedIds: string[]
if (state.sortBy === 'smart') {
- sortedIds = sortWorktreesSmart(allWorktrees, state.tabsByWorktree, repoMap, state.prCache).map(
- (w) => w.id
- )
+ sortedIds = sortWorktreesSmart(
+ allWorktrees,
+ state.tabsByWorktree,
+ repoMap,
+ state.prCache,
+ state.agentStatusByPaneKey
+ ).map((w) => w.id)
} else {
const sorted = [...allWorktrees].sort(
buildWorktreeComparator(
@@ -130,7 +134,9 @@ export function getVisibleWorktreeIds(): string[] {
state.tabsByWorktree,
repoMap,
state.prCache,
- Date.now()
+ Date.now(),
+ null,
+ state.agentStatusByPaneKey
)
)
sortedIds = sorted.map((w) => w.id)
diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx
index 6a386f91d43..c10ea0e0aec 100644
--- a/src/renderer/src/lib/agent-catalog.tsx
+++ b/src/renderer/src/lib/agent-catalog.tsx
@@ -255,9 +255,16 @@ export function AgentIcon({
agent,
size = 14
}: {
- agent: TuiAgent
+ agent: TuiAgent | null | undefined
size?: number
}): React.JSX.Element {
+ // Why: render a neutral question-mark glyph when the agent identity is not
+ // yet known. Before, the caller coerced null → 'claude', which caused Codex
+ // panes to briefly show the Claude icon until the first hook callback
+ // arrived.
+ if (!agent) {
+ return
+ }
if (agent === 'claude') {
return
}