Redesign Agents sidebar cards for cross-worktree scanning (#8420)

* Redesign Agents sidebar cards for cross-worktree scanning.

Lead with project and workspace identity, keep stable task titles across follow-up turns, and stop user replies from replacing agent status previews.

* fix(activity): honor generated-title setting and follow the active turn

Activity sidebar task-title/preview fixes surfaced in review of the
cross-worktree redesign:

- Apply generatedTitlesEnabled instead of ignoring it, so a disabled
  tabAutoGenerateTitle no longer resurfaces auto-titles (mirrors
  resolveTerminalTabTitle's gate).
- Guard orchestration labels with orchestrationLabelsMatchLiveDispatch so
  sticky completed metadata can't pin the title over new work.
- Prefer a substantive live prompt over history, and pick history by most
  recent startedAt (not longest), so the row title follows the active turn.
- Clear a stale status preview on a new substantive turn; only bridge a
  previous preview across a terse follow-up.
- Drop the duplicated workspace name in thread search text; index the
  branch instead.

Fixes two previously-red buildActivityEvents tests and adds coverage for
the generated-title gate and orchestration staleness.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Trevin Chow
2026-07-12 23:52:23 -07:00
committed by GitHub
co-authored by Jinjing
parent b69c6043e3
commit 06ea55181d
3 changed files with 613 additions and 159 deletions
@@ -67,6 +67,11 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { translate } from '@/i18n/i18n'
import {
getActivityThreadTaskTitle,
getActivityThreadWorkspaceTitle,
resolveActivityThreadStatusPreview
} from '@/lib/activity-thread-display'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
type ThreadReadFilter = 'all' | 'unread'
@@ -438,36 +443,28 @@ function agentMeta(event: ActivityEvent): string {
return event.state === 'waiting' ? `${agent} waiting` : `${agent} blocked`
}
// Why (label hierarchy): mirror DashboardAgentRow — the agent's last prompt
// IS what the agent is working on and is the primary signal users want at a
// glance. A user-renamed customTitle still wins (explicit rename intent), but
// the OSC-set live title ("Claude Code", "Codex", …) must NOT shadow the
// prompt: agent CLIs set that title eagerly, so preferring it would pin every
// row to the agent name and hide the actual turn. Fall back to a non-default
// liveTitle only when there is no prompt at all.
function paneTitleForEntry(entry: AgentStatusEntry, tab: TerminalTab): string {
const customTitle = tab.customTitle?.trim()
if (customTitle) {
return customTitle
}
const prompt = getAgentRowPrimaryText(entry)
if (prompt) {
return prompt
}
const liveTitle = tab.title?.trim()
const defaultTitle = tab.defaultTitle?.trim()
if (liveTitle && liveTitle !== defaultTitle) {
return liveTitle
}
return defaultTitle || liveTitle || 'Terminal'
// Why (label hierarchy): Activity rows need a stable task identity across
// follow-up turns. The live hook prompt tracks the current turn ("yes",
// "ok proceed") and must not replace the task title when scanning many agents
// across worktrees.
function paneTitleForEntry(
entry: AgentStatusEntry,
tab: TerminalTab,
generatedTitlesEnabled: boolean
): string {
return getActivityThreadTaskTitle({ entry, tab, generatedTitlesEnabled })
}
function paneTitleForEvent(event: ActivityEvent): string {
return paneTitleForEntry(event.entry, event.tab)
function paneTitleForEvent(event: ActivityEvent, generatedTitlesEnabled: boolean): string {
return paneTitleForEntry(event.entry, event.tab, generatedTitlesEnabled)
}
function responsePreviewForEntry(entry: AgentStatusEntry): string {
return entry.lastAssistantMessage?.trim() ?? ''
function statusPreviewForEntry(
entry: AgentStatusEntry,
agentState?: AgentStatusState | null,
previousPreview?: string
): string {
return resolveActivityThreadStatusPreview(entry, agentState, previousPreview)
}
function isActivityEventState(state: AgentStatusState): state is ActivityEventState {
@@ -772,7 +769,9 @@ export function buildActivityEvents(args: {
export function buildAgentPaneThreads(args: {
events: ActivityEvent[]
liveAgentByPaneKey: Record<string, ActivityLiveAgentSnapshot>
generatedTitlesEnabled?: boolean
}): AgentPaneThread[] {
const generatedTitlesEnabled = args.generatedTitlesEnabled === true
const byPaneKey = new Map<string, AgentPaneThread>()
for (const event of args.events) {
const paneKey = event.entry.paneKey
@@ -780,14 +779,14 @@ export function buildAgentPaneThreads(args: {
if (!existing) {
byPaneKey.set(paneKey, {
paneKey,
paneTitle: paneTitleForEvent(event),
paneTitle: paneTitleForEvent(event, generatedTitlesEnabled),
worktree: event.worktree,
repo: event.repo,
tab: event.tab,
agentType: event.agentType,
currentAgentState: null,
currentAgentEntry: null,
responsePreview: responsePreviewForEntry(event.entry),
responsePreview: statusPreviewForEntry(event.entry, event.state),
latestTimestamp: event.timestamp,
latestEvent: event,
events: [event],
@@ -802,10 +801,14 @@ export function buildAgentPaneThreads(args: {
existing.migrationUnsupportedPtyId ?? event.migrationUnsupportedPtyId
if (!existing.latestEvent || event.timestamp > existing.latestEvent.timestamp) {
existing.latestEvent = event
existing.paneTitle = paneTitleForEvent(event)
existing.paneTitle = paneTitleForEvent(event, generatedTitlesEnabled)
existing.agentType = event.agentType
existing.tab = event.tab
existing.responsePreview = responsePreviewForEntry(event.entry)
existing.responsePreview = statusPreviewForEntry(
event.entry,
event.state,
existing.responsePreview
)
existing.latestTimestamp = event.timestamp
}
}
@@ -815,14 +818,14 @@ export function buildAgentPaneThreads(args: {
if (!existing) {
byPaneKey.set(paneKey, {
paneKey,
paneTitle: paneTitleForEntry(liveAgent.entry, liveAgent.tab),
paneTitle: paneTitleForEntry(liveAgent.entry, liveAgent.tab, generatedTitlesEnabled),
worktree: liveAgent.worktree,
repo: liveAgent.repo,
tab: liveAgent.tab,
agentType: liveAgent.agentType,
currentAgentState: liveAgent.state,
currentAgentEntry: liveAgent.entry,
responsePreview: responsePreviewForEntry(liveAgent.entry),
responsePreview: statusPreviewForEntry(liveAgent.entry, liveAgent.state),
latestTimestamp: liveAgent.timestamp,
latestEvent: null,
events: [],
@@ -833,14 +836,18 @@ export function buildAgentPaneThreads(args: {
// Why: live metadata is the current thread identity. Historical events stay
// in the event list, but the row title/time/target must follow the active
// turn so a running agent never shows the previous prompt as primary.
existing.paneTitle = paneTitleForEntry(liveAgent.entry, liveAgent.tab)
existing.paneTitle = paneTitleForEntry(liveAgent.entry, liveAgent.tab, generatedTitlesEnabled)
existing.worktree = liveAgent.worktree
existing.repo = liveAgent.repo
existing.tab = liveAgent.tab
existing.agentType = liveAgent.agentType
existing.currentAgentState = liveAgent.state
existing.currentAgentEntry = liveAgent.entry
existing.responsePreview = responsePreviewForEntry(liveAgent.entry)
existing.responsePreview = statusPreviewForEntry(
liveAgent.entry,
liveAgent.state,
existing.responsePreview
)
existing.latestTimestamp = liveAgent.timestamp
}
@@ -928,6 +935,23 @@ export function ActivityThreadOptionsMenu({
)
}
function ActivityProjectLabel({ repo }: { repo: Repo | null }): React.JSX.Element {
const label =
repo?.displayName?.trim() ||
translate('auto.components.activity.ActivityPrototypePage.5651b216c6', 'Unknown project')
return (
<div className="flex min-w-0 items-center gap-1.5">
{repo ? <RepoBadgeMark color={repo.badgeColor} /> : null}
<span
className="min-w-0 truncate text-[11px] font-semibold uppercase tracking-[0.04em] text-muted-foreground"
title={label}
>
{label}
</span>
</div>
)
}
function EventRepoBadge({ repo }: { repo: Repo | null }): React.JSX.Element | null {
if (!repo) {
return null
@@ -1054,7 +1078,7 @@ function threadSearchText(thread: AgentPaneThread): string {
const latestEventText = latest
? `${agentTitle(latest)} ${agentSummary(latest)} ${agentMeta(latest)}`
: ''
return `${thread.paneTitle} ${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${formatAgentTypeLabel(thread.agentType)} ${stateLabel} ${currentPrompt} ${rawCurrentPrompt} ${currentSummary} ${thread.responsePreview} ${latestEventText}`.toLowerCase()
return `${thread.paneTitle} ${getActivityThreadWorkspaceTitle(thread.worktree)} ${thread.worktree.branch ?? ''} ${thread.repo?.displayName ?? ''} ${formatAgentTypeLabel(thread.agentType)} ${stateLabel} ${currentPrompt} ${rawCurrentPrompt} ${currentSummary} ${thread.responsePreview} ${latestEventText}`.toLowerCase()
}
export const ACTIVITY_SEARCH_QUERY_MAX_BYTES = 2 * 1024
@@ -1219,6 +1243,14 @@ function ThreadRow({
const renderedResponsePreview = activityThreadResponseRenderPreview({
responsePreview: thread.responsePreview
})
const workspaceTitle = getActivityThreadWorkspaceTitle(thread.worktree)
const taskTitle = thread.paneTitle
const agentLabel = formatAgentTypeLabel(thread.agentType)
const showStatusPreview =
!compactMode &&
renderedResponsePreview.length > 0 &&
renderedResponsePreview !== taskTitle &&
renderedResponsePreview !== workspaceTitle
return (
<div
data-current={selected ? 'true' : undefined}
@@ -1259,10 +1291,6 @@ function ThreadRow({
{thread.unread ? (
<span className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-r-full bg-primary" />
) : null}
{/* Why (right cluster aligned to title, not centered between rows):
parking the timestamp on the title row leaves the secondary row
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 items-start gap-1">
<ThreadAgentStateIndicator thread={thread} />
@@ -1271,128 +1299,126 @@ function ThreadRow({
</span>
</span>
<div className="min-w-0 flex-1">
<span
className={cn(
'min-w-0 text-xs leading-snug',
compactMode ? 'block truncate' : 'line-clamp-3 break-words',
thread.unread ? 'font-semibold text-foreground' : 'font-medium text-foreground'
)}
title={compactMode ? thread.paneTitle : undefined}
>
{thread.paneTitle}
</span>
{!compactMode && renderedResponsePreview ? (
<CommentMarkdown
content={renderedResponsePreview}
className={cn(
// Why: mirror the in-workspace agent card's compact response
// preview while keeping Activity rows to one scannable line;
// the content is capped before markdown parsing to keep large
// assistant summaries cheap in long Activity lists.
'mt-1 h-[1lh] min-w-0 overflow-hidden truncate whitespace-nowrap text-[11px] font-normal leading-snug text-muted-foreground/80',
'[&_*]:inline [&_*]:!m-0 [&_*]:!p-0 [&_*]:!whitespace-nowrap [&_br]:hidden [&_ol]:list-none [&_ul]:list-none'
)}
title={thread.responsePreview}
/>
) : null}
</div>
<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);
read → outline Bell that fades in on row hover and acts as
Mark-unread. Bare button (no shadcn outline) so it reads as
an inline cue rather than a discrete control square. */}
<span className="inline-flex size-4 shrink-0 items-center justify-center">
{thread.unread ? (
<FilledBellIcon
className="size-[13px] shrink-0 text-amber-500 drop-shadow-sm"
aria-label={translate(
'auto.components.activity.ActivityPrototypePage.beb2c19173',
'Unread'
<div className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1 space-y-0.5">
<ActivityProjectLabel repo={thread.repo} />
<div
className={cn(
'min-w-0 text-[13px] leading-snug',
compactMode ? 'truncate' : 'line-clamp-2 break-words',
thread.unread ? 'font-semibold text-foreground' : 'font-medium text-foreground'
)}
/>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onMarkUnread()
}}
onMouseDown={(event) => event.stopPropagation()}
title={workspaceTitle}
>
{workspaceTitle}
</div>
{taskTitle !== workspaceTitle ? (
<div
className={cn(
'min-w-0 text-[12px] leading-snug text-muted-foreground',
compactMode ? 'truncate' : 'line-clamp-2 break-words'
)}
title={taskTitle}
>
{taskTitle}
</div>
) : null}
{showStatusPreview ? (
<CommentMarkdown
content={renderedResponsePreview}
className={cn(
'h-[1lh] min-w-0 overflow-hidden truncate whitespace-nowrap text-[11px] font-normal leading-snug text-muted-foreground/80',
'[&_*]:inline [&_*]:!m-0 [&_*]:!p-0 [&_*]:!whitespace-nowrap [&_br]:hidden [&_ol]:list-none [&_ul]:list-none'
)}
title={thread.responsePreview}
/>
) : null}
<div className="flex min-w-0 items-center gap-1.5 pt-0.5">
<span className="shrink-0 text-[10px] text-muted-foreground/80">{agentLabel}</span>
{canJump ? (
<span
className={cn(
'group/unread flex size-4 shrink-0 cursor-pointer items-center justify-center rounded transition-all',
'hover:bg-accent/80 active:scale-95',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
)}
aria-label={translate(
'auto.components.activity.ActivityPrototypePage.59b131fbd9',
'Mark thread unread'
'ml-auto inline-flex shrink-0 items-center transition-opacity',
'can-hover:pointer-events-none can-hover:invisible can-hover:opacity-0',
'group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100'
)}
>
<Bell className="size-3 text-muted-foreground/40 can-hover:opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{translate(
'auto.components.activity.ActivityPrototypePage.59b131fbd9',
'Mark thread unread'
)}
</TooltipContent>
</Tooltip>
)}
</span>
<EventTime timestamp={thread.latestTimestamp} />
</span>
</div>
<div className="flex min-w-0 items-center gap-1.5 pl-[42px]">
<EventRepoBadge repo={thread.repo} />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
{thread.worktree.displayName}
</span>
{/* Why (Jump-to-workspace lives on the secondary row): the bell slot
on the title row already holds the unread/Mark-unread state, so
the navigation action gets its own slot down here aligned with
the worktree name. On hover-capable pointers, the hidden state
keeps the worktree-name's flex-1 width stable across hover. */}
{canJump ? (
<span
className={cn(
'ml-auto inline-flex shrink-0 items-center transition-opacity',
'can-hover:pointer-events-none can-hover:invisible can-hover:opacity-0',
'group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-xs"
aria-label={translate(
'auto.components.activity.ActivityPrototypePage.4616ea39fd',
'Jump to workspace'
)}
onClick={(event) => {
event.stopPropagation()
onJump()
}}
onMouseDown={(event) => event.stopPropagation()}
>
<ExternalLink className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{translate(
'auto.components.activity.ActivityPrototypePage.4616ea39fd',
'Jump to workspace'
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-xs"
aria-label={translate(
'auto.components.activity.ActivityPrototypePage.4616ea39fd',
'Jump to workspace'
)}
onClick={(event) => {
event.stopPropagation()
onJump()
}}
onMouseDown={(event) => event.stopPropagation()}
>
<ExternalLink className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{translate(
'auto.components.activity.ActivityPrototypePage.4616ea39fd',
'Jump to workspace'
)}
</TooltipContent>
</Tooltip>
</span>
) : null}
</div>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 pt-px">
<span className="inline-flex size-4 shrink-0 items-center justify-center">
{thread.unread ? (
<FilledBellIcon
className="size-[13px] shrink-0 text-amber-500 drop-shadow-sm"
aria-label={translate(
'auto.components.activity.ActivityPrototypePage.beb2c19173',
'Unread'
)}
/>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onMarkUnread()
}}
onMouseDown={(event) => event.stopPropagation()}
className={cn(
'group/unread flex size-4 shrink-0 cursor-pointer items-center justify-center rounded transition-all',
'hover:bg-accent/80 active:scale-95',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
)}
aria-label={translate(
'auto.components.activity.ActivityPrototypePage.59b131fbd9',
'Mark thread unread'
)}
>
<Bell className="size-3 text-muted-foreground/40 can-hover:opacity-0 transition-opacity group-hover:opacity-100 group-hover/unread:opacity-100" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{translate(
'auto.components.activity.ActivityPrototypePage.59b131fbd9',
'Mark thread unread'
)}
</TooltipContent>
</Tooltip>
)}
</TooltipContent>
</Tooltip>
</span>
) : null}
</span>
<EventTime timestamp={thread.latestTimestamp} />
</span>
</div>
</div>
</div>
</div>
)
@@ -1439,7 +1465,8 @@ export default function ActivityPrototypePage(): React.JSX.Element {
repoMap: getRepoMapFromState(s),
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey,
acknowledgeAgents: s.acknowledgeAgents,
unacknowledgeAgents: s.unacknowledgeAgents
unacknowledgeAgents: s.unacknowledgeAgents,
generatedTitlesEnabled: s.settings?.tabAutoGenerateTitle === true
}))
)
// Why: agentStatusEpoch is included in the dependency array (but not in the
@@ -1468,8 +1495,13 @@ export default function ActivityPrototypePage(): React.JSX.Element {
)
const allThreads = useMemo(
() => buildAgentPaneThreads({ events: allEvents, liveAgentByPaneKey }),
[allEvents, liveAgentByPaneKey]
() =>
buildAgentPaneThreads({
events: allEvents,
liveAgentByPaneKey,
generatedTitlesEnabled: storeData.generatedTitlesEnabled
}),
[allEvents, liveAgentByPaneKey, storeData.generatedTitlesEnabled]
)
const selectedPaneKeyIsLive =
selectedPaneKey === null || allThreads.some((thread) => thread.paneKey === selectedPaneKey)
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest'
import {
getActivityThreadStatusPreview,
getActivityThreadTaskTitle,
getActivityThreadWorkspaceTitle,
isTerseAgentFollowUpPrompt,
resolveActivityThreadStatusPreview
} from './activity-thread-display'
describe('isTerseAgentFollowUpPrompt', () => {
it('flags common short follow-ups', () => {
expect(isTerseAgentFollowUpPrompt('yes')).toBe(true)
expect(isTerseAgentFollowUpPrompt('ok proceed')).toBe(true)
expect(isTerseAgentFollowUpPrompt('Looks good.')).toBe(true)
})
it('keeps substantive prompts', () => {
expect(isTerseAgentFollowUpPrompt('Compare gpt5 claude prompting')).toBe(false)
expect(isTerseAgentFollowUpPrompt('Skill creator codex port')).toBe(false)
})
})
describe('getActivityThreadWorkspaceTitle', () => {
it('prefers the stored display name', () => {
expect(
getActivityThreadWorkspaceTitle({
displayName: 'Compound engineering plugin',
branch: 'main'
})
).toBe('Compound engineering plugin')
})
})
describe('getActivityThreadTaskTitle', () => {
const tab = {
customTitle: null,
generatedTitle: 'Refactor auth middleware',
title: 'Claude',
defaultTitle: 'Claude'
}
it('prefers custom title, then sticky orchestration labels', () => {
expect(
getActivityThreadTaskTitle({
entry: {
prompt: 'yes',
stateHistory: [],
orchestration: {
taskId: 'task-1',
dispatchId: 'ctx-1',
displayName: 'Fix checkout race'
}
},
tab: { ...tab, customTitle: 'My rename' },
generatedTitlesEnabled: true
})
).toBe('My rename')
expect(
getActivityThreadTaskTitle({
entry: { prompt: 'yes', stateHistory: [] },
tab,
generatedTitlesEnabled: true
})
).toBe('Refactor auth middleware')
})
it('ignores terse live prompts and uses generated title or history', () => {
expect(
getActivityThreadTaskTitle({
entry: {
prompt: 'yes',
stateHistory: [{ state: 'working', prompt: 'Skill creator codex port', startedAt: 1 }]
},
tab: { ...tab, generatedTitle: undefined },
generatedTitlesEnabled: true
})
).toBe('Skill creator codex port')
})
it('picks the most recent substantive prompt from history, not the longest', () => {
expect(
getActivityThreadTaskTitle({
entry: {
prompt: 'yes',
stateHistory: [
{
state: 'done',
prompt: 'Refactor the entire authentication middleware layer',
startedAt: 1
},
{ state: 'working', prompt: 'Fix logout', startedAt: 2 }
]
},
tab: { ...tab, generatedTitle: undefined },
generatedTitlesEnabled: false
})
).toBe('Fix logout')
})
it('ignores the generated title when generated titles are disabled', () => {
expect(
getActivityThreadTaskTitle({
entry: {
prompt: 'yes',
stateHistory: [{ state: 'working', prompt: 'Wire up the export button', startedAt: 1 }]
},
tab,
generatedTitlesEnabled: false
})
).toBe('Wire up the export button')
})
it('keeps orchestration labels across terse follow-ups but yields to new work', () => {
const orchestration = { taskId: 'task-1', dispatchId: 'ctx-1', displayName: 'Fix checkout race' }
// Terse follow-up → still the same orchestration task.
expect(
getActivityThreadTaskTitle({
entry: { prompt: 'yes', stateHistory: [], orchestration },
tab: { ...tab, generatedTitle: undefined },
generatedTitlesEnabled: false
})
).toBe('Fix checkout race')
// Substantive non-dispatch prompt → pane moved on; stale label must not pin.
expect(
getActivityThreadTaskTitle({
entry: { prompt: 'Investigate the flaky login test', stateHistory: [], orchestration },
tab: { ...tab, generatedTitle: undefined },
generatedTitlesEnabled: false
})
).toBe('Investigate the flaky login test')
})
it('parses dispatch task bodies from history when the live prompt is a follow-up', () => {
expect(
getActivityThreadTaskTitle({
entry: {
prompt: 'ok',
stateHistory: [
{
state: 'done',
prompt: `You are working inside Orca, a multi-agent IDE. Your task ID is: task-1
=== TASK ===
Compare gpt5 claude prompting`,
startedAt: 1
}
]
},
tab: { ...tab, generatedTitle: undefined },
generatedTitlesEnabled: false
})
).toBe('Compare gpt5 claude prompting')
})
})
describe('getActivityThreadStatusPreview', () => {
it('shows tool activity while working and assistant replies otherwise', () => {
expect(
getActivityThreadStatusPreview({
state: 'working',
toolName: 'Bash',
toolInput: 'pnpm test',
prompt: 'Run tests'
})
).toBe('Bash: pnpm test')
expect(
getActivityThreadStatusPreview(
{
state: 'done',
prompt: 'yes',
lastAssistantMessage: 'Implemented the skill creator port.'
},
'done'
)
).toBe('Implemented the skill creator port.')
})
it('rejects hook previews that echo the live user prompt', () => {
expect(
getActivityThreadStatusPreview({
state: 'working',
prompt: 'yes',
lastAssistantMessage: 'yes'
})
).toBe('')
})
it('surfaces interrupted sessions explicitly', () => {
expect(
getActivityThreadStatusPreview({
state: 'done',
interrupted: true,
prompt: 'Ship it'
})
).toBe('Interrupted by user')
})
})
describe('resolveActivityThreadStatusPreview', () => {
it('keeps the previous assistant preview when a new ping mislabels the user prompt', () => {
expect(
resolveActivityThreadStatusPreview(
{
state: 'working',
prompt: 'yes',
lastAssistantMessage: 'yes'
},
'working',
'Implemented the skill creator port.'
)
).toBe('Implemented the skill creator port.')
})
})
@@ -0,0 +1,207 @@
import type {
AgentStateHistoryEntry,
AgentStatusEntry,
AgentStatusState
} from '../../../shared/agent-status-types'
import type { TerminalTab, Worktree } from '../../../shared/types'
import {
getAgentRowPrimaryText,
isOrcaDispatchPrompt,
orchestrationLabelsMatchLiveDispatch
} from './agent-row-primary-text'
// Why: follow-up replies ("yes", "ok proceed") are valid hook prompts but are
// terrible scan labels for a cross-worktree agent list — treat them as non-titles.
const TERSE_FOLLOW_UP_PATTERN =
/^(yes|no|ok|yep|nope|sure|thanks|thank you|please|proceed|continue|go ahead|lgtm|done|looks good|ok proceed)\.?$/i
export function isTerseAgentFollowUpPrompt(prompt: string): boolean {
const trimmed = prompt.trim()
if (!trimmed) {
return true
}
if (trimmed.length > 24) {
return false
}
return TERSE_FOLLOW_UP_PATTERN.test(trimmed)
}
function taskTitleFromPrompt(prompt: string): string | null {
if (isOrcaDispatchPrompt(prompt)) {
const preview = getAgentRowPrimaryText({ prompt })
return preview || null
}
const trimmed = prompt.trim()
if (!trimmed || isTerseAgentFollowUpPrompt(trimmed)) {
return null
}
return trimmed
}
function bestTaskPromptFromHistory(history: readonly AgentStateHistoryEntry[]): string | null {
// Why: the most recent substantive turn is the current task — older prompts
// (even longer ones) must not shadow newer work. Compare startedAt rather
// than array position so out-of-order history still resolves the latest turn.
let best: string | null = null
let bestStartedAt = Number.NEGATIVE_INFINITY
for (const historyEntry of history) {
const candidate = taskTitleFromPrompt(historyEntry.prompt)
if (!candidate) {
continue
}
if (historyEntry.startedAt >= bestStartedAt) {
best = candidate
bestStartedAt = historyEntry.startedAt
}
}
return best
}
// Why: orchestration labels are the stable identity across follow-up turns, but
// sticky metadata can outlive the task. Trust the label only when it still
// describes the live work: a dispatch turn must share the task id (mirrors
// getAgentRowPrimaryText), and a substantive non-dispatch prompt means the pane
// moved on to new work — a terse follow-up ("yes") is still the same task.
function orchestrationLabelForEntry(
entry: Pick<AgentStatusEntry, 'orchestration' | 'prompt'>
): string | null {
const label =
entry.orchestration?.displayName?.trim() || entry.orchestration?.taskTitle?.trim() || ''
if (!label) {
return null
}
if (isOrcaDispatchPrompt(entry.prompt)) {
return orchestrationLabelsMatchLiveDispatch(entry) ? label : null
}
if (taskTitleFromPrompt(entry.prompt)) {
return null
}
return label
}
/** Friendly workspace label — matches the sidebar worktree card's primary name. */
export function getActivityThreadWorkspaceTitle(
worktree: Pick<Worktree, 'displayName' | 'branch'>
): string {
const displayName = worktree.displayName?.trim()
const branch = worktree.branch?.trim()
if (displayName) {
return displayName
}
return branch || 'Workspace'
}
/** Stable task identity for Activity sidebar rows — not the latest follow-up turn. */
export function getActivityThreadTaskTitle(args: {
entry: Pick<AgentStatusEntry, 'orchestration' | 'prompt' | 'stateHistory'>
tab: Pick<TerminalTab, 'customTitle' | 'generatedTitle' | 'title' | 'defaultTitle'>
generatedTitlesEnabled: boolean
}): string {
const customTitle = args.tab.customTitle?.trim()
if (customTitle) {
return customTitle
}
const orchestrationLabel = orchestrationLabelForEntry(args.entry)
if (orchestrationLabel) {
return orchestrationLabel
}
// Why: respect the user's tabAutoGenerateTitle setting — a disabled generated
// title must not resurface here (mirrors resolveTerminalTabTitle's gate).
const generatedTitle = args.generatedTitlesEnabled ? args.tab.generatedTitle?.trim() : ''
if (generatedTitle) {
return generatedTitle
}
// Why: a substantive live prompt is genuine new work and must win — the row
// title follows the active turn (see buildAgentPaneThreads). Only a terse
// follow-up ("yes") falls through to the prior task recorded in history.
const liveTitle = taskTitleFromPrompt(args.entry.prompt)
if (liveTitle) {
return liveTitle
}
const historical = bestTaskPromptFromHistory(args.entry.stateHistory)
if (historical) {
return historical
}
const liveTabTitle = args.tab.title?.trim()
const defaultTabTitle = args.tab.defaultTitle?.trim()
if (liveTabTitle && liveTabTitle !== defaultTabTitle) {
return liveTabTitle
}
return defaultTabTitle || liveTabTitle || 'Terminal'
}
function isMislabeledUserPrompt(text: string, entry: Pick<AgentStatusEntry, 'prompt'>): boolean {
const trimmed = text.trim()
if (!trimmed) {
return true
}
if (isTerseAgentFollowUpPrompt(trimmed)) {
return true
}
// Why: some hooks echo the live user prompt into assistant preview fields
// between turns; never surface that as the agent's latest reply.
if (trimmed === entry.prompt.trim()) {
return true
}
return false
}
/** Latest agent activity line — tool step while working, assistant reply otherwise. */
export function getActivityThreadStatusPreview(
entry: Pick<
AgentStatusEntry,
'state' | 'toolName' | 'toolInput' | 'lastAssistantMessage' | 'interrupted' | 'prompt'
>,
agentState?: AgentStatusState | null
): string {
if (entry.interrupted === true) {
return 'Interrupted by user'
}
const state = agentState ?? entry.state
if (state === 'working') {
const toolName = entry.toolName?.trim() ?? ''
const toolInput = entry.toolInput?.trim() ?? ''
if (toolName && toolInput) {
return `${toolName}: ${toolInput}`
}
if (toolName) {
return toolName
}
}
const assistant = entry.lastAssistantMessage?.trim() ?? ''
if (assistant && !isMislabeledUserPrompt(assistant, entry)) {
return assistant
}
return ''
}
/** Keep the last good assistant preview when a new hook ping clears or mislabels it. */
export function resolveActivityThreadStatusPreview(
entry: Pick<
AgentStatusEntry,
'state' | 'toolName' | 'toolInput' | 'lastAssistantMessage' | 'interrupted' | 'prompt'
>,
agentState: AgentStatusState | null | undefined,
previousPreview?: string
): string {
const next = getActivityThreadStatusPreview(entry, agentState)
if (next) {
return next
}
// Why: only bridge a transient empty/mislabeled ping within the SAME turn. A
// substantive live prompt marks a new turn, so the prior turn's reply must not
// linger as the current status (a fresh working turn shows no stale preview).
if (!isTerseAgentFollowUpPrompt(entry.prompt)) {
return ''
}
const previous = previousPreview?.trim() ?? ''
if (previous && !isMislabeledUserPrompt(previous, entry)) {
return previous
}
return ''
}