Show running agents in Activity threads (#1754)

* fix: show running agents in activity threads

Co-authored-by: Orca <help@stably.ai>

* polish activity thread prompt alignment

Co-authored-by: Orca <help@stably.ai>

* polish activity thread controls

Co-authored-by: Orca <help@stably.ai>

* match activity thread agent state icons

Co-authored-by: Orca <help@stably.ai>

* polish activity thread state icon size

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-13 12:33:41 -07:00 committed by GitHub
parent f4009fdaa9
commit 2d5fb175d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 220 additions and 25 deletions

View File

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { buildActivityEvents } from './ActivityPrototypePage'
function makeRepo(): Repo {
@ -67,13 +71,45 @@ function makeWorkingEntryWithPriorDone(): AgentStatusEntry {
}
}
function makeWorkingEntryWithoutHistory(): AgentStatusEntry {
return {
state: 'working',
prompt: 'New run',
updatedAt: 3_000,
stateStartedAt: 3_000,
paneKey: 'tab-1:1',
terminalTitle: 'Claude',
stateHistory: [],
agentType: 'claude'
}
}
function makeRetainedDoneEntry(tab: TerminalTab): RetainedAgentEntry {
return {
entry: {
state: 'done',
prompt: 'Retained prior run',
updatedAt: 1_000,
stateStartedAt: 1_000,
paneKey: 'tab-1:1',
terminalTitle: 'Claude',
stateHistory: [],
agentType: 'claude'
},
worktreeId: 'wt-1',
tab,
agentType: 'claude',
startedAt: 1_000
}
}
describe('buildActivityEvents', () => {
it('keeps a prior done event after the same pane starts working again', () => {
const repo = makeRepo()
const worktree = makeWorktree()
const tab = makeTab()
const events = buildActivityEvents({
const result = buildActivityEvents({
agentStatusByPaneKey: {
'tab-1:1': makeWorkingEntryWithPriorDone()
},
@ -83,14 +119,69 @@ describe('buildActivityEvents', () => {
},
worktreeMap: new Map([[worktree.id, worktree]]),
repoMap: new Map([[repo.id, repo]]),
acknowledgedAgentsByPaneKey: {}
acknowledgedAgentsByPaneKey: {},
now: 2_000
})
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
expect(result.events).toHaveLength(1)
expect(result.events[0]).toMatchObject({
state: 'done',
timestamp: 1_000
})
expect(events[0].entry.prompt).toBe('First prompt')
expect(result.events[0].entry.prompt).toBe('First prompt')
expect(result.liveAgentStateByPaneKey['tab-1:1']).toBe('working')
})
it('does not keep showing a stale live agent as running', () => {
const repo = makeRepo()
const worktree = makeWorktree()
const tab = makeTab()
const result = buildActivityEvents({
agentStatusByPaneKey: {
'tab-1:1': makeWorkingEntryWithPriorDone()
},
retainedAgentsByPaneKey: {},
tabsByWorktree: {
[worktree.id]: [tab]
},
worktreeMap: new Map([[worktree.id, worktree]]),
repoMap: new Map([[repo.id, repo]]),
acknowledgedAgentsByPaneKey: {},
now: 2_000 + AGENT_STATUS_STALE_AFTER_MS + 1
})
expect(result.events).toHaveLength(1)
expect(result.liveAgentStateByPaneKey['tab-1:1']).toBeUndefined()
})
it('overlays fresh live state onto retained-only activity for a reused pane key', () => {
const repo = makeRepo()
const worktree = makeWorktree()
const tab = makeTab()
const result = buildActivityEvents({
agentStatusByPaneKey: {
'tab-1:1': makeWorkingEntryWithoutHistory()
},
retainedAgentsByPaneKey: {
'tab-1:1': makeRetainedDoneEntry(tab)
},
tabsByWorktree: {
[worktree.id]: [tab]
},
worktreeMap: new Map([[worktree.id, worktree]]),
repoMap: new Map([[repo.id, repo]]),
acknowledgedAgentsByPaneKey: {},
now: 3_000
})
expect(result.events).toHaveLength(1)
expect(result.events[0]).toMatchObject({
state: 'done',
timestamp: 1_000
})
expect(result.events[0].entry.prompt).toBe('Retained prior run')
expect(result.liveAgentStateByPaneKey['tab-1:1']).toBe('working')
})
})

View File

@ -13,8 +13,13 @@ import {
TerminalSquare
} from 'lucide-react'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
import { AgentIcon } from '@/lib/agent-catalog'
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
import {
agentTypeToIconAgent,
formatAgentTypeLabel,
isExplicitAgentStatusFresh
} from '@/lib/agent-status'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
@ -38,15 +43,17 @@ import {
type ActivityTerminalPortalTarget
} from './activity-terminal-portal'
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
import type {
AgentStateHistoryEntry,
AgentStatusEntry,
AgentStatusState,
AgentType
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStateHistoryEntry,
type AgentStatusEntry,
type AgentStatusState,
type AgentType
} from '../../../../shared/agent-status-types'
type ThreadReadFilter = 'all' | 'unread'
type ActivityEventState = Extract<AgentStatusState, 'done' | 'blocked' | 'waiting'>
type ActivityLiveAgentState = Extract<AgentStatusState, 'working' | 'blocked' | 'waiting'>
type ActivityEvent = {
id: string
@ -70,6 +77,7 @@ type AgentPaneThread = {
worktree: Worktree
repo: Repo | null
agentType: AgentType
currentAgentState: ActivityLiveAgentState | null
latestEvent: ActivityEvent
events: ActivityEvent[]
unread: boolean
@ -309,6 +317,20 @@ function isActivityEventState(state: AgentStatusState): state is ActivityEventSt
return state === 'done' || state === 'blocked' || state === 'waiting'
}
function isActivityLiveAgentState(state: AgentStatusState): state is ActivityLiveAgentState {
return state === 'working' || state === 'blocked' || state === 'waiting'
}
function freshActivityLiveAgentState(
entry: AgentStatusEntry,
now: number
): ActivityLiveAgentState | null {
if (!isActivityLiveAgentState(entry.state)) {
return null
}
return isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) ? entry.state : null
}
// Why: per-pane cap guarantees each agent appears in the left list even when one pane has a long history.
const EVENTS_PER_PANE_CAP = 5
@ -405,10 +427,15 @@ export function buildActivityEvents(args: {
worktreeMap: Map<string, Worktree>
repoMap: Map<string, Repo>
acknowledgedAgentsByPaneKey: Record<string, number>
}): ActivityEvent[] {
now: number
}): {
events: ActivityEvent[]
liveAgentStateByPaneKey: Record<string, ActivityLiveAgentState>
} {
const events: ActivityEvent[] = []
const seenEventIds = new Set<string>()
const tabContext = new Map<string, { worktree: Worktree; tab: TerminalTab }>()
const liveAgentStateByPaneKey: Record<string, ActivityLiveAgentState> = {}
for (const worktree of args.worktreeMap.values()) {
const tabs = args.tabsByWorktree[worktree.id] ?? []
@ -428,6 +455,14 @@ export function buildActivityEvents(args: {
continue
}
const ackAt = args.acknowledgedAgentsByPaneKey[paneKey] ?? 0
// Why: live state is a per-pane overlay computed once from the live entry,
// not a property duplicated onto every event in the thread. Retained-only
// panes don't contribute here — the agent is gone, so there is no live
// state to overlay.
const liveState = freshActivityLiveAgentState(entry, args.now)
if (liveState) {
liveAgentStateByPaneKey[paneKey] = liveState
}
appendActivityEventsForEntry({
events,
seenEventIds,
@ -475,10 +510,13 @@ export function buildActivityEvents(args: {
break
}
}
return capped
return { events: capped, liveAgentStateByPaneKey }
}
function buildAgentPaneThreads(events: ActivityEvent[]): AgentPaneThread[] {
function buildAgentPaneThreads(
events: ActivityEvent[],
liveAgentStateByPaneKey: Record<string, ActivityLiveAgentState>
): AgentPaneThread[] {
const byPaneKey = new Map<string, AgentPaneThread>()
for (const event of events) {
const paneKey = event.entry.paneKey
@ -490,6 +528,9 @@ function buildAgentPaneThreads(events: ActivityEvent[]): AgentPaneThread[] {
worktree: event.worktree,
repo: event.repo,
agentType: event.agentType,
// Why: live status is a per-pane overlay from the hook stream, looked
// up once by paneKey rather than merged across historical events.
currentAgentState: liveAgentStateByPaneKey[paneKey] ?? null,
latestEvent: event,
events: [event],
unread: event.unread
@ -548,6 +589,35 @@ function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | nu
)
}
function threadAgentState(thread: AgentPaneThread): AgentStatusState {
return thread.currentAgentState ?? thread.latestEvent.state
}
function threadAgentStateLabel(thread: AgentPaneThread): string {
const state = threadAgentState(thread)
if (!thread.currentAgentState && state === 'done' && thread.latestEvent.entry.interrupted) {
return 'Interrupted'
}
return agentStateLabel(state)
}
function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): React.JSX.Element {
const state = threadAgentState(thread)
const label = threadAgentStateLabel(thread)
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex size-4 shrink-0 items-center justify-center">
<AgentStateDot state={state} size="md" />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
function ThreadRow({
thread,
selected,
@ -602,18 +672,21 @@ function ThreadRow({
full-width for the repo badge + branch name, which used to get
truncated when the right cluster ate horizontal space. */}
<div className="flex min-w-0 items-start gap-2">
<span className="inline-flex shrink-0 pt-[3px]">
<AgentIcon agent={agentTypeToIconAgent(thread.agentType)} size={14} />
<span className="inline-flex shrink-0 items-start gap-1">
<ThreadAgentStateIndicator thread={thread} />
<span className="inline-flex shrink-0 pt-px">
<AgentIcon agent={agentTypeToIconAgent(thread.agentType)} size={14} />
</span>
</span>
<span
className={cn(
'line-clamp-3 min-w-0 flex-1 break-words text-[11px] leading-snug',
'line-clamp-3 min-w-0 flex-1 break-words text-xs leading-snug',
thread.unread ? 'font-semibold text-foreground' : 'font-medium text-foreground'
)}
>
{thread.paneTitle}
</span>
<span className="inline-flex shrink-0 items-center gap-1.5 pt-[3px]">
<span className="inline-flex shrink-0 items-center gap-1.5 pt-px">
{/* Why (bell matches WorktreeCard pattern): unread amber filled
bell as a static, non-interactive cue (selecting the thread
auto-marks it read, so a Mark-read button would be redundant);
@ -735,10 +808,34 @@ export default function ActivityPrototypePage(): React.JSX.Element {
unacknowledgeAgents: s.unacknowledgeAgents
}))
)
// Why: agentStatusEpoch is included in the dependency array (but not in the
// computation itself) so the memo recomputes when freshness boundaries expire,
// even if no new PTY data arrives.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const allEvents = useMemo(() => buildActivityEvents(storeData), [storeData])
const { events: allEvents, liveAgentStateByPaneKey } = useMemo(
() =>
buildActivityEvents({
agentStatusByPaneKey: storeData.agentStatusByPaneKey,
retainedAgentsByPaneKey: storeData.retainedAgentsByPaneKey,
tabsByWorktree: storeData.tabsByWorktree,
worktreeMap: storeData.worktreeMap,
repoMap: storeData.repoMap,
acknowledgedAgentsByPaneKey: storeData.acknowledgedAgentsByPaneKey,
// Why: Date.now() is read inside the memo (not as a dep) so stale-decay
// recalculates whenever agentStatusEpoch ticks. The epoch bumps when the
// freshness boundary crosses, driving re-evaluation without coupling to
// wall-clock time directly.
now: Date.now()
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[storeData, agentStatusEpoch]
)
const allThreads = useMemo(() => buildAgentPaneThreads(allEvents), [allEvents])
const allThreads = useMemo(
() => buildAgentPaneThreads(allEvents, liveAgentStateByPaneKey),
[allEvents, liveAgentStateByPaneKey]
)
const visibleThreads = useMemo(() => {
const trimmedQuery = query.trim().toLowerCase()
@ -753,8 +850,9 @@ export default function ActivityPrototypePage(): React.JSX.Element {
return true
}
const latest = thread.latestEvent
const stateLabel = threadAgentStateLabel(thread)
const text =
`${thread.paneTitle} ${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${agentTitle(latest)} ${agentSummary(latest)} ${agentMeta(latest)}`.toLowerCase()
`${thread.paneTitle} ${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${stateLabel} ${agentTitle(latest)} ${agentSummary(latest)} ${agentMeta(latest)}`.toLowerCase()
return text.includes(trimmedQuery)
})
}, [allThreads, readFilter, query, selectedPaneKey])
@ -1020,7 +1118,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
type="button"
variant="outline"
size="sm"
className="size-8 shrink-0 p-0 text-muted-foreground hover:text-foreground"
className="size-8 shrink-0 border-input bg-transparent p-0 text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-transparent dark:hover:bg-accent dark:hover:text-accent-foreground"
aria-label="Thread list options"
>
<MoreVertical className="size-3.5" />
@ -1085,8 +1183,14 @@ export default function ActivityPrototypePage(): React.JSX.Element {
<div className="flex shrink-0 items-start gap-4 border-b border-border px-4 pt-2 pb-3">
<div className="min-w-0">
<div className="flex min-w-0 items-start gap-2">
<span className="inline-flex shrink-0 pt-[3px]">
<AgentIcon agent={agentTypeToIconAgent(selectedThread.agentType)} size={16} />
<span className="inline-flex shrink-0 items-start gap-1">
<ThreadAgentStateIndicator thread={selectedThread} />
<span className="inline-flex shrink-0 pt-[3px]">
<AgentIcon
agent={agentTypeToIconAgent(selectedThread.agentType)}
size={16}
/>
</span>
</span>
<h2 className="line-clamp-3 break-words text-sm font-semibold leading-snug">
{selectedThread.paneTitle}