diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx new file mode 100644 index 00000000000..cdb868ede58 --- /dev/null +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { cn } from '@/lib/utils' + +// Why: shared state-dot primitive so the dashboard and the sidebar's agent +// hover render the same state vocabulary identically. The dot sits next to the +// agent icon (Claude/Codex/etc.) — they are two distinct glyphs: one for *who* +// (icon) and one for *what state* (dot). Keeping them separate keeps each +// glyph scannable at a glance instead of fused into a single decorated icon. + +export type AgentDotState = + | 'working' + | 'blocked' + | 'waiting' + | 'done' + | 'idle' + // Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard) + // collapses blocked + waiting into a single "needs attention" state. Keep + // this as a distinct member so that flow can render without inventing a new + // vocabulary, but treat it identically to `blocked` visually. + | 'permission' + +export function agentStateLabel(state: AgentDotState): string { + switch (state) { + case 'working': + return 'Working' + case 'blocked': + return 'Blocked' + case 'waiting': + return 'Waiting for input' + case 'done': + return 'Done' + case 'idle': + return 'Idle' + case 'permission': + return 'Needs attention' + } +} + +type Props = { + state: AgentDotState + size?: 'sm' | 'md' + className?: string +} + +export const AgentStateDot = React.memo(function AgentStateDot({ + state, + size = 'sm', + className +}: Props): React.JSX.Element { + const box = size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5' + const inner = size === 'md' ? 'size-2' : 'size-1.5' + + if (state === 'working') { + return ( + + + + ) + } + + return ( + + + + ) +}) diff --git a/src/renderer/src/components/sidebar/AgentStatusHover.tsx b/src/renderer/src/components/sidebar/AgentStatusHover.tsx new file mode 100644 index 00000000000..117189b2210 --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentStatusHover.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useMemo } from 'react' +import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' +import { useAppStore } from '@/store' +import { useDashboardData } from '@/components/dashboard/useDashboardData' +import { enrichGroupsWithRetained } from '@/components/dashboard/useRetainedAgents' +import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' + +type AgentStatusHoverProps = { + worktreeId: string + children: React.ReactNode +} + +// Why: the hovercard must render the exact same information the per-worktree +// dashboard card shows — hook-reported agents plus any retained "done" +// snapshots. Sharing the dashboard's data pipeline (useDashboardData + +// enrichGroupsWithRetained) and row component (DashboardAgentRow) guarantees +// the two surfaces cannot drift. Retention state itself is hoisted into the +// store (see useRetainedAgentsSync wired at App level), so dismissing in the +// hover reflects in the dashboard and vice versa. +const AgentStatusHover = React.memo(function AgentStatusHover({ + worktreeId, + children +}: AgentStatusHoverProps) { + const liveGroups = useDashboardData() + const retained = useAppStore((s) => s.retainedAgentsByPaneKey) + const removeAgentStatus = useAppStore((s) => s.removeAgentStatus) + const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent) + const setActiveWorktree = useAppStore((s) => s.setActiveWorktree) + const setActiveTab = useAppStore((s) => s.setActiveTab) + const setActiveView = useAppStore((s) => s.setActiveView) + + const agents = useMemo(() => { + const enriched = enrichGroupsWithRetained(liveGroups, retained) + for (const group of enriched) { + for (const wt of group.worktrees) { + if (wt.worktree.id === worktreeId) { + return wt.agents + } + } + } + return [] + }, [liveGroups, retained, worktreeId]) + + // Why: mirror AgentDashboard.handleDismissAgent so dismissing in either + // surface has identical effect — removes the live store entry and the + // retained snapshot if either is present. + const handleDismissAgent = useCallback( + (paneKey: string) => { + removeAgentStatus(paneKey) + dismissRetainedAgent(paneKey) + }, + [removeAgentStatus, dismissRetainedAgent] + ) + + // Why: clicking a row activates the specific tab the agent runs in. Retained + // rows can outlive their tab, so fall back to worktree-only activation when + // the tab is no longer present. + const handleActivateAgentTab = useCallback( + (tabId: string) => { + setActiveWorktree(worktreeId) + setActiveView('terminal') + const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] + if (tabs.some((t) => t.id === tabId)) { + setActiveTab(tabId) + } + }, + [worktreeId, setActiveWorktree, setActiveTab, setActiveView] + ) + + return ( + + {children} + + {agents.length === 0 ? ( +
No running agents
+ ) : ( +
+
+ Running agents ({agents.length}) +
+
+ {agents.map((agent) => ( +
+ +
+ ))} +
+
+ )} +
+
+ ) +}) + +export default AgentStatusHover diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index fbb20b99eae..06a9f14f07e 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -1,32 +1,27 @@ import React from 'react' import { cn } from '@/lib/utils' -import type { WorktreeStatus } from '@/lib/worktree-status' -type StatusIndicatorProps = React.ComponentProps<'span'> & { - status: WorktreeStatus +export type Status = 'active' | 'working' | 'permission' | 'inactive' + +type StatusIndicatorProps = { + status: Status + className?: string } const StatusIndicator = React.memo(function StatusIndicator({ status, - className, - ...props + className }: StatusIndicatorProps) { if (status === 'working') { return ( - + ) } return ( - + s.tabsByWorktree[worktree.id] ?? EMPTY_TABS) const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS) + const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) + const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) const branch = branchDisplayName(worktree.branch) const isFolder = repo ? isFolderRepo(repo) : false @@ -121,11 +127,53 @@ const WorktreeCard = React.memo(function WorktreeCard({ const isDeleting = deleteState?.isDeleting ?? false - // Derive status - const status: WorktreeStatus = useMemo( - () => getWorktreeStatus(tabs, browserTabs), - [tabs, browserTabs] - ) + // Derive status — explicit agent status (OSC 9999) takes precedence over + // heuristic title parsing, per the design doc's precedence rule. + const status: WorktreeStatus = useMemo(() => { + const liveTabs = tabs.filter((tab) => tab.ptyId) + // Why: browser-only worktrees are still active from the user's point of + // view even when they have no PTY-backed terminal. The sidebar filter + // already treats them as active, so every navigation surface must reuse + // that rule instead of showing a misleading inactive dot. + const hasTerminals = liveTabs.length > 0 || browserTabs.length > 0 + if (!hasTerminals) { + return 'inactive' + } + + // Why: explicit status only wins while it is fresh. Once it gets stale, the + // design doc says the coarse icon should fall back to live title heuristics + // so an old "done" report does not mask a new permission prompt. + const explicitEntries = Object.values(agentStatusByPaneKey).filter((e) => + liveTabs.some((t) => e.paneKey.startsWith(`${t.id}:`)) + ) + const freshEntries = explicitEntries.filter((entry) => + isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS) + ) + if (freshEntries.some((e) => e.state === 'blocked' || e.state === 'waiting')) { + return 'permission' + } + if (freshEntries.some((e) => e.state === 'working')) { + return 'working' + } + // Why done maps to 'active': a completed agent still has a live terminal — + // 'inactive' (gray dot) would incorrectly suggest nothing is there. + if (freshEntries.some((e) => e.state === 'done')) { + return 'active' + } + + // Fall through to heuristic title-based detection + if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) { + return 'permission' + } + if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) { + return 'working' + } + return liveTabs.length > 0 ? 'active' : 'inactive' + // Why: agentStatusEpoch is a cache-busting counter, not data consumed by + // the memo body. It forces re-derivation when an agent status entry crosses + // the freshness threshold so the visual status updates without polling. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabs, browserTabs, agentStatusByPaneKey, agentStatusEpoch]) const showPR = cardProps.includes('pr') const showCI = cardProps.includes('ci') @@ -253,7 +301,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ {/* Status indicator on the left */} {(cardProps.includes('status') || cardProps.includes('unread')) && (
- {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 }