From 6c97d78cf37292b96fb7c4f103ce3fd955065a98 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Wed, 13 May 2026 21:23:01 -0700 Subject: [PATCH] fix(activity): cap assistant preview rendering Co-authored-by: Orca --- .../activity/ActivityPrototypePage.test.ts | 214 +++++++++++------- .../activity/ActivityPrototypePage.tsx | 115 +++++++++- 2 files changed, 235 insertions(+), 94 deletions(-) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index e66d6a8e278..3d99aa78491 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -6,6 +6,7 @@ import { import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { + activityThreadResponseRenderPreview, activityThreadMatchesSearchQuery, buildActivityEvents, buildAgentPaneThreads @@ -98,7 +99,8 @@ function makeRetainedDoneEntry(tab: TerminalTab): RetainedAgentEntry { paneKey: 'tab-1:1', terminalTitle: 'Claude', stateHistory: [], - agentType: 'claude' + agentType: 'claude', + lastAssistantMessage: 'Retained response preview' }, worktreeId: 'wt-1', tab, @@ -107,23 +109,42 @@ function makeRetainedDoneEntry(tab: TerminalTab): RetainedAgentEntry { } } +function makeActivityResult(args: { + entries?: Record + retained?: Record + tab?: TerminalTab + now?: number +}): ReturnType { + const repo = makeRepo() + const worktree = makeWorktree() + const tab = args.tab ?? makeTab() + + return buildActivityEvents({ + agentStatusByPaneKey: args.entries ?? {}, + retainedAgentsByPaneKey: args.retained ?? {}, + tabsByWorktree: { + [worktree.id]: [tab] + }, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[repo.id, repo]]), + acknowledgedAgentsByPaneKey: {}, + now: args.now ?? 3_000 + }) +} + +function makeThreads(result: ReturnType) { + return buildAgentPaneThreads({ + events: result.events, + liveAgentByPaneKey: result.liveAgentByPaneKey + }) +} + 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 result = buildActivityEvents({ - agentStatusByPaneKey: { + const result = makeActivityResult({ + entries: { '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 }) @@ -136,10 +157,7 @@ describe('buildActivityEvents', () => { expect(result.liveAgentByPaneKey['tab-1:1'].state).toBe('working') expect(result.liveAgentByPaneKey['tab-1:1'].entry.prompt).toBe('Second prompt') - const threads = buildAgentPaneThreads({ - events: result.events, - liveAgentByPaneKey: result.liveAgentByPaneKey - }) + const threads = makeThreads(result) expect(threads).toHaveLength(1) expect(threads[0].paneTitle).toBe('Second prompt') @@ -148,21 +166,10 @@ describe('buildActivityEvents', () => { }) it('does not keep showing a stale live agent as running', () => { - const repo = makeRepo() - const worktree = makeWorktree() - const tab = makeTab() - - const result = buildActivityEvents({ - agentStatusByPaneKey: { + const result = makeActivityResult({ + entries: { '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 }) @@ -171,28 +178,13 @@ describe('buildActivityEvents', () => { }) it('creates a thread for a fresh running agent with no historical events', () => { - const repo = makeRepo() - const worktree = makeWorktree() - const tab = makeTab() - - const result = buildActivityEvents({ - agentStatusByPaneKey: { + const result = makeActivityResult({ + entries: { 'tab-1:1': makeWorkingEntryWithoutHistory() - }, - retainedAgentsByPaneKey: {}, - tabsByWorktree: { - [worktree.id]: [tab] - }, - worktreeMap: new Map([[worktree.id, worktree]]), - repoMap: new Map([[repo.id, repo]]), - acknowledgedAgentsByPaneKey: {}, - now: 3_000 + } }) - const threads = buildAgentPaneThreads({ - events: result.events, - liveAgentByPaneKey: result.liveAgentByPaneKey - }) + const threads = makeThreads(result) expect(result.events).toHaveLength(0) expect(threads).toHaveLength(1) @@ -207,32 +199,20 @@ describe('buildActivityEvents', () => { }) it('matches a custom-titled live thread by its current prompt', () => { - const repo = makeRepo() - const worktree = makeWorktree() const tab = { ...makeTab(), customTitle: 'Pinned agent title' } const entry = { ...makeWorkingEntryWithoutHistory(), prompt: 'Investigate activity live prompt search' } - const result = buildActivityEvents({ - agentStatusByPaneKey: { + const result = makeActivityResult({ + entries: { 'tab-1:1': entry }, - retainedAgentsByPaneKey: {}, - tabsByWorktree: { - [worktree.id]: [tab] - }, - worktreeMap: new Map([[worktree.id, worktree]]), - repoMap: new Map([[repo.id, repo]]), - acknowledgedAgentsByPaneKey: {}, - now: 3_000 + tab }) - const threads = buildAgentPaneThreads({ - events: result.events, - liveAgentByPaneKey: result.liveAgentByPaneKey - }) + const threads = makeThreads(result) expect(threads[0].paneTitle).toBe('Pinned agent title') expect( @@ -243,25 +223,93 @@ describe('buildActivityEvents', () => { ).toBe(true) }) - it('overlays fresh live state onto retained-only activity for a reused pane key', () => { - const repo = makeRepo() - const worktree = makeWorktree() + it('surfaces the current live assistant response as the thread preview', () => { + const entry = { + ...makeWorkingEntryWithoutHistory(), + lastAssistantMessage: 'I updated the tests and checked the activity row.' + } + + const result = makeActivityResult({ + entries: { + 'tab-1:1': entry + } + }) + + const threads = makeThreads(result) + + expect(threads[0].responsePreview).toBe('I updated the tests and checked the activity row.') + expect( + activityThreadMatchesSearchQuery({ + thread: threads[0], + searchQuery: 'checked the activity row' + }) + ).toBe(true) + }) + + it('caps rendered assistant response preview without changing searchable thread text', () => { + const longResponse = `${'Preview details '.repeat(80)}activity row searchable tail` + const entry = { + ...makeWorkingEntryWithoutHistory(), + lastAssistantMessage: longResponse + } + + const result = makeActivityResult({ + entries: { + 'tab-1:1': entry + } + }) + + const threads = makeThreads(result) + const renderedPreview = activityThreadResponseRenderPreview({ + responsePreview: threads[0].responsePreview + }) + + expect(renderedPreview.length).toBeLessThan(longResponse.length) + expect(renderedPreview.endsWith('...')).toBe(true) + expect( + activityThreadMatchesSearchQuery({ + thread: threads[0], + searchQuery: 'searchable tail' + }) + ).toBe(true) + }) + + it('does not leave a lone surrogate when capping the rendered response preview', () => { + const renderedPreview = activityThreadResponseRenderPreview({ + responsePreview: `${'a'.repeat(319)}😀tail` + }) + const beforeEllipsis = renderedPreview.slice(0, -3) + const lastCode = beforeEllipsis.charCodeAt(beforeEllipsis.length - 1) + + expect(lastCode >= 0xd800 && lastCode <= 0xdbff).toBe(false) + }) + + it('surfaces the retained done assistant response as the thread preview', () => { const tab = makeTab() - const result = buildActivityEvents({ - agentStatusByPaneKey: { - 'tab-1:1': makeWorkingEntryWithoutHistory() - }, - retainedAgentsByPaneKey: { + const result = makeActivityResult({ + retained: { 'tab-1:1': makeRetainedDoneEntry(tab) }, - tabsByWorktree: { - [worktree.id]: [tab] + tab + }) + + const threads = makeThreads(result) + + expect(threads[0].responsePreview).toBe('Retained response preview') + }) + + it('overlays fresh live state onto retained-only activity for a reused pane key', () => { + const tab = makeTab() + + const result = makeActivityResult({ + entries: { + 'tab-1:1': makeWorkingEntryWithoutHistory() }, - worktreeMap: new Map([[worktree.id, worktree]]), - repoMap: new Map([[repo.id, repo]]), - acknowledgedAgentsByPaneKey: {}, - now: 3_000 + retained: { + 'tab-1:1': makeRetainedDoneEntry(tab) + }, + tab }) expect(result.events).toHaveLength(1) @@ -272,13 +320,11 @@ describe('buildActivityEvents', () => { expect(result.events[0].entry.prompt).toBe('Retained prior run') expect(result.liveAgentByPaneKey['tab-1:1'].state).toBe('working') - const threads = buildAgentPaneThreads({ - events: result.events, - liveAgentByPaneKey: result.liveAgentByPaneKey - }) + const threads = makeThreads(result) expect(threads).toHaveLength(1) expect(threads[0].paneTitle).toBe('New run') + expect(threads[0].responsePreview).toBe('') expect(threads[0].latestTimestamp).toBe(3_000) expect(threads[0].events[0].entry.prompt).toBe('Retained prior run') }) diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index e5b8cd36616..940541081a4 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -29,8 +29,10 @@ import { useSidebarResize } from '@/hooks/useSidebarResize' import { Button } from '@/components/ui/button' import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' @@ -38,6 +40,7 @@ import { Toggle } from '@/components/ui/toggle' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' +import CommentMarkdown from '../sidebar/CommentMarkdown' import { setActivityTerminalPortals, type ActivityTerminalPortalTarget @@ -90,6 +93,7 @@ type AgentPaneThread = { agentType: AgentType currentAgentState: ActivityLiveAgentState | null currentAgentEntry: AgentStatusEntry | null + responsePreview: string latestTimestamp: number latestEvent: ActivityEvent | null events: ActivityEvent[] @@ -110,6 +114,7 @@ type ActivityTerminalPortalDomStatus = { type ActivityTerminalPortalSlotId = 'primary' | 'secondary' const ACTIVITY_TERMINAL_LOADING_LABEL_DELAY_MS = 180 +const ACTIVITY_THREAD_RESPONSE_RENDER_PREVIEW_MAX_LENGTH = 320 const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { year: 'numeric', @@ -139,6 +144,33 @@ function formatRelativeTime(timestamp: number): string { return relativeTimeFormatter.format(diffDays, 'day') } +function truncatePreservingSurrogates(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value + } + const truncated = value.slice(0, maxLength) + const lastCode = truncated.charCodeAt(truncated.length - 1) + if (lastCode >= 0xd800 && lastCode <= 0xdbff) { + return truncated.slice(0, -1) + } + return truncated +} + +export function activityThreadResponseRenderPreview({ + responsePreview +}: { + responsePreview: string +}): string { + const trimmed = responsePreview.trim() + if (trimmed.length <= ACTIVITY_THREAD_RESPONSE_RENDER_PREVIEW_MAX_LENGTH) { + return trimmed + } + return `${truncatePreservingSurrogates( + trimmed, + ACTIVITY_THREAD_RESPONSE_RENDER_PREVIEW_MAX_LENGTH + ).trimEnd()}...` +} + function paneIdFromPaneKey(paneKey: string): number | null { const colon = paneKey.indexOf(':') const tail = colon > 0 ? paneKey.slice(colon + 1) : '' @@ -329,6 +361,10 @@ function paneTitleForEvent(event: ActivityEvent): string { return paneTitleForEntry(event.entry, event.tab) } +function responsePreviewForEntry(entry: AgentStatusEntry): string { + return entry.lastAssistantMessage?.trim() ?? '' +} + function isActivityEventState(state: AgentStatusState): state is ActivityEventState { return state === 'done' || state === 'blocked' || state === 'waiting' } @@ -554,6 +590,7 @@ export function buildAgentPaneThreads(args: { agentType: event.agentType, currentAgentState: null, currentAgentEntry: null, + responsePreview: responsePreviewForEntry(event.entry), latestTimestamp: event.timestamp, latestEvent: event, events: [event], @@ -568,6 +605,7 @@ export function buildAgentPaneThreads(args: { existing.paneTitle = paneTitleForEvent(event) existing.agentType = event.agentType existing.tab = event.tab + existing.responsePreview = responsePreviewForEntry(event.entry) existing.latestTimestamp = event.timestamp } } @@ -584,6 +622,7 @@ export function buildAgentPaneThreads(args: { agentType: liveAgent.agentType, currentAgentState: liveAgent.state, currentAgentEntry: liveAgent.entry, + responsePreview: responsePreviewForEntry(liveAgent.entry), latestTimestamp: liveAgent.timestamp, latestEvent: null, events: [], @@ -601,6 +640,7 @@ export function buildAgentPaneThreads(args: { existing.agentType = liveAgent.agentType existing.currentAgentState = liveAgent.state existing.currentAgentEntry = liveAgent.entry + existing.responsePreview = responsePreviewForEntry(liveAgent.entry) existing.latestTimestamp = liveAgent.timestamp } @@ -667,7 +707,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} ${currentSummary} ${latestEventText}`.toLowerCase() + return `${thread.paneTitle} ${thread.worktree.displayName} ${thread.repo?.displayName ?? ''} ${formatAgentTypeLabel(thread.agentType)} ${stateLabel} ${currentPrompt} ${currentSummary} ${thread.responsePreview} ${latestEventText}`.toLowerCase() } export function activityThreadMatchesSearchQuery({ @@ -698,19 +738,41 @@ function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): Rea ) } +function isEventFromNestedInteractiveElement( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + const interactiveTarget = target.closest( + 'a, button, input, select, textarea, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])' + ) + return ( + interactiveTarget instanceof HTMLElement && + interactiveTarget !== currentTarget && + currentTarget.contains(interactiveTarget) + ) +} + function ThreadRow({ thread, selected, onSelect, onJump, - onMarkUnread + onMarkUnread, + compactMode }: { thread: AgentPaneThread selected: boolean onSelect: () => void onJump: () => void onMarkUnread: () => void + compactMode: boolean }): React.JSX.Element { + const renderedResponsePreview = activityThreadResponseRenderPreview({ + responsePreview: thread.responsePreview + }) return (
{ + // Why: response markdown can contain links; keyboard activation on a + // nested link should follow the link instead of selecting the row. + if (isEventFromNestedInteractiveElement(event.target, event.currentTarget)) { + return + } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onSelect() @@ -757,14 +824,32 @@ function ThreadRow({ - - {thread.paneTitle} - +
+ + {thread.paneTitle} + + {!compactMode && renderedResponsePreview ? ( + + ) : null} +
{/* Why (bell matches WorktreeCard pattern): unread → amber filled bell as a static, non-interactive cue (selecting the thread @@ -850,6 +935,7 @@ function ThreadRow({ export default function ActivityPrototypePage(): React.JSX.Element { const [readFilter, setReadFilter] = useState('all') const [query, setQuery] = useState('') + const [compactMode, setCompactMode] = useState(false) const [selectedPaneKey, setSelectedPaneKey] = useState(null) const [displayedPaneKey, setDisplayedPaneKey] = useState(null) const [activePortalSlotId, setActivePortalSlotId] = @@ -1200,6 +1286,14 @@ export default function ActivityPrototypePage(): React.JSX.Element { More options + setCompactMode(checked === true)} + onSelect={(event) => event.preventDefault()} + > + Compact mode + + markAllThreadsRead()} disabled={!hasUnreadThreads} @@ -1219,6 +1313,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { onSelect={() => selectThread(thread)} onJump={() => jumpToWorkspace(thread)} onMarkUnread={() => markThreadUnread(thread)} + compactMode={compactMode} /> ))} {visibleThreads.length === 0 ? (