mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
feat(sidebar): surface agent status in worktree cards + hover
Renders explicit AgentStatus entries in the existing sidebar: - WorktreeCard/StatusIndicator pull from the agent-status slice so a working/blocked/waiting agent is visible per worktree. - AgentStateDot is a shared indicator reused by the card + hover. - AgentStatusHover expands the card on hover with per-agent details. - smart-sort orders worktrees by live agent activity so the user sees hot worktrees at the top. Pure UI — consumes data produced in the prior PRs.
This commit is contained in:
@@ -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 (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
|
||||
aria-label={agentStateLabel(state)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block rounded-full border-2 border-emerald-500 border-t-transparent animate-spin',
|
||||
inner
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
|
||||
aria-label={agentStateLabel(state)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'block rounded-full',
|
||||
inner,
|
||||
state === 'blocked' || state === 'waiting' || state === 'permission'
|
||||
? 'bg-amber-500 animate-pulse'
|
||||
: state === 'done'
|
||||
? 'bg-sky-500/80'
|
||||
: 'bg-neutral-500/40'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
@@ -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 (
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" className="w-72 p-3 text-xs">
|
||||
{agents.length === 0 ? (
|
||||
<div className="py-1 text-center text-muted-foreground">No running agents</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
|
||||
Running agents ({agents.length})
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-border/60">
|
||||
{agents.map((agent) => (
|
||||
<div key={agent.paneKey} className="py-1">
|
||||
<DashboardAgentRow
|
||||
agent={agent}
|
||||
onDismiss={handleDismissAgent}
|
||||
onActivate={handleActivateAgentTab}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
})
|
||||
|
||||
export default AgentStatusHover
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}>
|
||||
<span className="block size-2 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className={cn('inline-flex h-3 w-3 shrink-0 items-center justify-center', className)}>
|
||||
<span
|
||||
className={cn(
|
||||
'block size-2 rounded-full',
|
||||
@@ -42,4 +37,3 @@ const StatusIndicator = React.memo(function StatusIndicator({
|
||||
})
|
||||
|
||||
export default StatusIndicator
|
||||
export type { WorktreeStatus as Status }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: the worktree card centralizes sidebar card state (selection, drag, agent status, git info, context menu) in one cohesive component so sidebar rendering doesn't fan out across files. */
|
||||
import React, { useEffect, useMemo, useCallback, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -7,9 +8,12 @@ import StatusIndicator from './StatusIndicator'
|
||||
import CacheTimer from './CacheTimer'
|
||||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
|
||||
import AgentStatusHover from './AgentStatusHover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { getWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status'
|
||||
import { type WorktreeStatus } from '@/lib/worktree-status'
|
||||
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
|
||||
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import type { Worktree, Repo, PRInfo, IssueInfo } from '../../../../shared/types'
|
||||
import {
|
||||
@@ -102,6 +106,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
|
||||
const tabs = useAppStore((s) => 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')) && (
|
||||
<div className="flex flex-col items-center justify-start pt-[2px] gap-2 shrink-0">
|
||||
{cardProps.includes('status') && <StatusIndicator status={status} />}
|
||||
{cardProps.includes('status') && (
|
||||
<AgentStatusHover worktreeId={worktree.id}>
|
||||
<span>
|
||||
<StatusIndicator status={status} />
|
||||
</span>
|
||||
</AgentStatusHover>
|
||||
)}
|
||||
|
||||
{cardProps.includes('unread') && (
|
||||
<Tooltip>
|
||||
|
||||
@@ -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) —
|
||||
|
||||
@@ -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> = {}): TerminalTab {
|
||||
}
|
||||
}
|
||||
|
||||
function makeAgentStatusEntry(
|
||||
overrides: Partial<AgentStatusEntry> & { 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', () => {
|
||||
|
||||
@@ -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<string, AgentStatusEntry>
|
||||
): 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<string, AgentStatusEntry[]>()
|
||||
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<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
now: number = Date.now(),
|
||||
smartSortOverrides: Record<string, SmartSortOverride> | null = null
|
||||
smartSortOverrides: Record<string, SmartSortOverride> | null = null,
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
|
||||
): (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<string, TerminalTab[]>,
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, PRCacheEntry> | null
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
|
||||
): 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<string, TerminalTab[]> | null,
|
||||
repoMap: Map<string, Repo> | null,
|
||||
prCache: Record<string, PRCacheEntry> | null,
|
||||
now: number = Date.now()
|
||||
now: number = Date.now(),
|
||||
agentStatusByPaneKey?: Record<string, AgentStatusEntry>
|
||||
): 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <AgentLetterIcon letter="?" size={size} />
|
||||
}
|
||||
if (agent === 'claude') {
|
||||
return <ClaudeIcon size={size} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user