diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index d296f5da471..ccb57a05e80 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -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 + generatedTitlesEnabled?: boolean }): AgentPaneThread[] { + const generatedTitlesEnabled = args.generatedTitlesEnabled === true const byPaneKey = new Map() 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 ( +
+ {repo ? : null} + + {label} + +
+ ) +} + 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 (
) : 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. */}
@@ -1271,128 +1299,126 @@ function ThreadRow({
- - {thread.paneTitle} - - {!compactMode && renderedResponsePreview ? ( - - ) : null} -
- - {/* 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. */} - - {thread.unread ? ( - +
+ +
- ) : ( - - -
+ {taskTitle !== workspaceTitle ? ( +
+ {taskTitle} +
+ ) : null} + {showStatusPreview ? ( + + ) : null} +
+ {agentLabel} + {canJump ? ( + - - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.59b131fbd9', - 'Mark thread unread' - )} - - - )} - - - -
-
- - - {thread.worktree.displayName} - - {/* 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 ? ( - - - - - - - {translate( - 'auto.components.activity.ActivityPrototypePage.4616ea39fd', - 'Jump to workspace' + + + + + + {translate( + 'auto.components.activity.ActivityPrototypePage.4616ea39fd', + 'Jump to workspace' + )} + + + + ) : null} +
+
+ + + {thread.unread ? ( + + ) : ( + + + + + + {translate( + 'auto.components.activity.ActivityPrototypePage.59b131fbd9', + 'Mark thread unread' + )} + + )} - - - - ) : null} + + +
+
+
) @@ -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) diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts new file mode 100644 index 00000000000..c553ceb02f3 --- /dev/null +++ b/src/renderer/src/lib/activity-thread-display.test.ts @@ -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.') + }) +}) diff --git a/src/renderer/src/lib/activity-thread-display.ts b/src/renderer/src/lib/activity-thread-display.ts new file mode 100644 index 00000000000..f1933a06146 --- /dev/null +++ b/src/renderer/src/lib/activity-thread-display.ts @@ -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 +): 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 +): 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 + tab: Pick + 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): 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 '' +}