fix(activity): cap assistant preview rendering

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-13 21:23:01 -07:00
co-authored by Orca
parent 3d07c2a28a
commit 6c97d78cf3
2 changed files with 235 additions and 94 deletions
@@ -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<string, AgentStatusEntry>
retained?: Record<string, RetainedAgentEntry>
tab?: TerminalTab
now?: number
}): ReturnType<typeof buildActivityEvents> {
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<typeof buildActivityEvents>) {
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')
})
@@ -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 (
<div
data-current={selected ? 'true' : undefined}
@@ -718,6 +780,11 @@ function ThreadRow({
role="button"
tabIndex={0}
onKeyDown={(event) => {
// 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({
<AgentIcon agent={agentTypeToIconAgent(thread.agentType)} size={14} />
</span>
</span>
<span
className={cn(
'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>
<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
@@ -850,6 +935,7 @@ function ThreadRow({
export default function ActivityPrototypePage(): React.JSX.Element {
const [readFilter, setReadFilter] = useState<ThreadReadFilter>('all')
const [query, setQuery] = useState('')
const [compactMode, setCompactMode] = useState(false)
const [selectedPaneKey, setSelectedPaneKey] = useState<string | null>(null)
const [displayedPaneKey, setDisplayedPaneKey] = useState<string | null>(null)
const [activePortalSlotId, setActivePortalSlotId] =
@@ -1200,6 +1286,14 @@ export default function ActivityPrototypePage(): React.JSX.Element {
<TooltipContent side="bottom">More options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuCheckboxItem
checked={compactMode}
onCheckedChange={(checked) => setCompactMode(checked === true)}
onSelect={(event) => event.preventDefault()}
>
Compact mode
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => 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 ? (