Tool result
{item.isError ?
: null}
diff --git a/src/components/fresh-agent/FreshAgentTranscript.tsx b/src/components/fresh-agent/FreshAgentTranscript.tsx
index 23d5a1c8..7bb5ad2f 100644
--- a/src/components/fresh-agent/FreshAgentTranscript.tsx
+++ b/src/components/fresh-agent/FreshAgentTranscript.tsx
@@ -1,5 +1,5 @@
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
+import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
+import { ChevronDown, ChevronRight, ChevronUp, Loader2, X } from 'lucide-react'
import SlotReel from '@/components/fresh-agent/shared/SlotReel'
import { getToolPreview } from '@/components/fresh-agent/shared/tool-preview'
import { cn } from '@/lib/utils'
@@ -227,11 +227,17 @@ function buildBlocks(
function filterTurnsForDisplay(
turns: FreshAgentTurn[],
options: TranscriptDisplayOptions,
+ isStreaming: boolean,
): FreshAgentTurn[] {
return turns
- .map((turn) => {
+ .map((turn, index) => {
const items = turn.items.filter((item) => shouldDisplayTranscriptItem(item, options))
- if (turn.items.length > 0 && items.length === 0) return null
+ if (turn.items.length > 0 && items.length === 0) {
+ if (isStreaming && index === turns.length - 1) {
+ return { ...turn, items: [] }
+ }
+ return null
+ }
return items === turn.items ? turn : { ...turn, items }
})
.filter((turn): turn is FreshAgentTurn => turn !== null)
@@ -288,14 +294,20 @@ function selectLiveActivityBlockId(
}
})
- if (isStreaming) return latestActivityBlockId
+ if (isStreaming) {
+ const lastTurn = turns[turns.length - 1]
+ if (lastTurn && !lastTurn.items.some((item) => shouldDisplayTranscriptItem(item, options))) {
+ return null
+ }
+ return latestActivityBlockId
+ }
return latestTrailingThinkingBlockId
}
function FreshAgentThinkingRow({ text }: { text: string }) {
const [expanded, setExpanded] = useState(false)
return (
-
+
)
}
-export function FreshAgentTranscript({
- turns,
- canFork = false,
- agentLabel,
- showModel = false,
- showThinking = true,
- showTools = false,
- showTimecodes,
- isStreaming = false,
- onForkFromTurn,
- onRewindToTurn,
-}: {
+const AT_BOTTOM_THRESHOLD = 24
+const TRANSCRIPT_LINE_HEIGHT = 40
+const TRANSCRIPT_PAGE_OVERLAP = 40
+
+function computeAtBottom(node: HTMLElement): boolean {
+ return node.scrollHeight - node.scrollTop - node.clientHeight < AT_BOTTOM_THRESHOLD
+}
+
+export type FreshAgentTranscriptHandle = {
+ scrollByLine: (direction: 1 | -1) => void
+ scrollByPage: (direction: 1 | -1) => void
+ scrollToTop: () => void
+ scrollToBottom: () => void
+}
+
+export type FreshAgentTranscriptProps = {
turns: FreshAgentTurn[]
canFork?: boolean
agentLabel?: string
@@ -529,20 +570,34 @@ export function FreshAgentTranscript({
isStreaming?: boolean
onForkFromTurn?: (turnId: string) => void
onRewindToTurn?: (turn: FreshAgentTurn) => void
-}) {
+}
+
+export const FreshAgentTranscript = forwardRef
(function FreshAgentTranscript({
+ turns,
+ canFork = false,
+ agentLabel,
+ showModel = false,
+ showThinking = true,
+ showTools = false,
+ showTimecodes,
+ isStreaming = false,
+ onForkFromTurn,
+ onRewindToTurn,
+}, ref) {
const scrollerRef = useRef(null)
const [atBottom, setAtBottom] = useState(true)
const [newMessages, setNewMessages] = useState(0)
const [contextMenu, setContextMenu] = useState(null)
const [sheetTurn, setSheetTurn] = useState(null)
+ const [glomTarget, setGlomTarget] = useState<{ index: number; text: string } | null>(null)
const coarsePointer = useCoarsePointer()
const resolvedShowTimecodes = showTimecodes ?? showModel
const displayOptions = useMemo(() => ({
showThinking,
}), [showThinking])
const displayTurns = useMemo(() => (
- filterTurnsForDisplay(turns, displayOptions)
- ), [displayOptions, turns])
+ filterTurnsForDisplay(turns, displayOptions, isStreaming)
+ ), [displayOptions, turns, isStreaming])
const liveActivityBlockId = useMemo(
() => selectLiveActivityBlockId(displayTurns, isStreaming, displayOptions),
[displayOptions, displayTurns, isStreaming],
@@ -568,6 +623,39 @@ export function FreshAgentTranscript({
}).join('|')
), [displayTurns])
+ const recomputeGlom = useCallback(() => {
+ const scroller = scrollerRef.current
+ if (!scroller) {
+ setGlomTarget(null)
+ return
+ }
+ const scrollerTop = scroller.getBoundingClientRect().top
+ const userTurnEls = scroller.querySelectorAll('[data-turn-role="user"]')
+ let target: { index: number; text: string } | null = null
+ userTurnEls.forEach((el) => {
+ if (el.getBoundingClientRect().top < scrollerTop) {
+ const indexAttr = el.getAttribute('data-turn-index')
+ if (indexAttr == null) return
+ const index = Number(indexAttr)
+ if (Number.isNaN(index)) return
+ const turn = displayTurns[index]
+ if (!turn) return
+ const text = turnPlainText(turn)
+ if (!text) return
+ target = { index, text }
+ }
+ })
+ setGlomTarget(target)
+ }, [displayTurns])
+
+ const handleGlomClick = useCallback(() => {
+ if (!glomTarget) return
+ const scroller = scrollerRef.current
+ if (!scroller) return
+ const el = scroller.querySelector(`[data-turn-index="${glomTarget.index}"]`)
+ el?.scrollIntoView?.({ block: 'start' })
+ }, [glomTarget])
+
const handleTurnContextMenu = useCallback((event: React.MouseEvent, turn: FreshAgentTurn) => {
setContextMenu({ x: event.clientX, y: event.clientY, turn })
}, [])
@@ -584,6 +672,35 @@ export function FreshAgentTranscript({
onOpenActions: coarsePointer ? handleOpenActions : undefined,
}), [canFork, coarsePointer, handleOpenActions, handleTurnContextMenu, onForkFromTurn, onRewindToTurn])
+ useImperativeHandle(ref, () => ({
+ scrollByLine: (direction) => {
+ const node = scrollerRef.current
+ if (!node) return
+ node.scrollTop += direction * TRANSCRIPT_LINE_HEIGHT
+ setAtBottom(computeAtBottom(node))
+ },
+ scrollByPage: (direction) => {
+ const node = scrollerRef.current
+ if (!node) return
+ const delta = Math.max(1, node.clientHeight - TRANSCRIPT_PAGE_OVERLAP)
+ node.scrollTop += direction * delta
+ setAtBottom(computeAtBottom(node))
+ },
+ scrollToTop: () => {
+ const node = scrollerRef.current
+ if (!node) return
+ node.scrollTop = 0
+ setAtBottom(computeAtBottom(node))
+ },
+ scrollToBottom: () => {
+ const node = scrollerRef.current
+ if (!node) return
+ node.scrollTop = node.scrollHeight
+ setAtBottom(true)
+ setNewMessages(0)
+ },
+ }), [])
+
useEffect(() => {
const node = scrollerRef.current
if (!node) return
@@ -595,6 +712,10 @@ export function FreshAgentTranscript({
}
}, [atBottom, transcriptSignature])
+ useEffect(() => {
+ recomputeGlom()
+ }, [recomputeGlom, transcriptSignature])
+
return (
{
const node = event.currentTarget
- setAtBottom(node.scrollHeight - node.scrollTop - node.clientHeight < 24)
+ setAtBottom(computeAtBottom(node))
+ recomputeGlom()
}}
>
{displayTurns.map((turn, index) => (
@@ -618,9 +740,23 @@ export function FreshAgentTranscript({
continuation={index > 0 && displayTurns[index - 1]?.role === turn.role}
liveActivityBlockId={liveActivityBlockId}
displayOptions={displayOptions}
+ isStreamingLastTurn={isStreaming && index === displayTurns.length - 1}
+ index={index}
/>
))}
+ {glomTarget ? (
+
+
+ {glomTarget.text}
+
+ ) : null}
)
-}
+})
export default memo(FreshAgentTranscript)
diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx
index e7847995..2cd349c4 100644
--- a/src/components/fresh-agent/FreshAgentView.tsx
+++ b/src/components/fresh-agent/FreshAgentView.tsx
@@ -48,7 +48,7 @@ import { finalizeCodingAgentSessionName } from '@/store/codingAgentNaming'
import { FreshAgentApprovalBanner } from './FreshAgentApprovalBanner'
import { FreshAgentApprovalCard } from './FreshAgentApprovalCard'
import FreshAgentQuestionBanner from './FreshAgentQuestionBanner'
-import { FreshAgentTranscript } from './FreshAgentTranscript'
+import { FreshAgentTranscript, type FreshAgentTranscriptHandle } from './FreshAgentTranscript'
import { FreshAgentComposer, type FreshAgentComposerHandle } from './FreshAgentComposer'
import { FreshAgentDiffPanel } from './FreshAgentDiffPanel'
import { FreshAgentSidebar } from './FreshAgentSidebar'
@@ -353,6 +353,59 @@ function isPlainTextKey(event: ReactKeyboardEvent): boolean {
&& !event.altKey
}
+function isInteractiveTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false
+ return Boolean(target.closest(
+ 'input, textarea, select, button, a[href], [contenteditable=""], [contenteditable="true"], [role="button"], [role="menuitem"]',
+ ))
+}
+
+function isTranscriptNavigationKey(event: ReactKeyboardEvent): boolean {
+ if (event.ctrlKey || event.metaKey || event.altKey) return false
+ switch (event.key) {
+ case 'ArrowUp':
+ case 'ArrowDown':
+ case 'PageUp':
+ case 'PageDown':
+ case 'Home':
+ case 'End':
+ return true
+ default:
+ return false
+ }
+}
+
+function scrollTranscriptByKey(
+ event: ReactKeyboardEvent,
+ handle: FreshAgentTranscriptHandle | null,
+): boolean {
+ if (!handle) return false
+ switch (event.key) {
+ case 'ArrowDown':
+ handle.scrollByLine(1)
+ break
+ case 'ArrowUp':
+ handle.scrollByLine(-1)
+ break
+ case 'PageDown':
+ handle.scrollByPage(1)
+ break
+ case 'PageUp':
+ handle.scrollByPage(-1)
+ break
+ case 'Home':
+ handle.scrollToTop()
+ break
+ case 'End':
+ handle.scrollToBottom()
+ break
+ default:
+ return false
+ }
+ event.preventDefault()
+ return true
+}
+
export function FreshAgentView({
tabId,
paneId,
@@ -420,6 +473,9 @@ export function FreshAgentView({
return state.freshAgent.sessions[sessionKey]
})
const refreshRequest = useAppSelector((state) => state.panes.refreshRequestsByPane?.[tabId]?.[paneId] ?? null)
+ const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
+ const activePaneId = useAppSelector((state) => state.panes.activePane[tabId])
+ const isActivePane = !hidden && activeTabId === tabId && activePaneId === paneId
const [snapshot, setSnapshot] = useState(null)
const snapshotRef = useRef(null)
const commitSnapshot = useCallback((next: FreshAgentSnapshot | null) => {
@@ -450,6 +506,8 @@ export function FreshAgentView({
), [paneContent.sessionType, snapshot?.capabilities])
const paneContentRef = useRef(paneContent)
const composerRef = useRef(null)
+ const transcriptRef = useRef(null)
+ const paneRootRef = useRef(null)
paneContentRef.current = paneContent
const setLocalEcho = useCallback((next: LocalEcho | null) => {
setLocalEchoState(next)
@@ -1262,15 +1320,36 @@ export function FreshAgentView({
const isBusy = BUSY_STATES.has(effectiveStatus)
const sessionEnded = effectiveStatus === 'exited' || effectiveStatus === 'create-failed'
const sessionErrorMessage = (agentSession as { lastError?: string } | undefined)?.lastError ?? null
+ // sessionEnded gates everything: a stale snapshot can still claim
+ // capabilities.send after the provider process died.
+ const canSend = !sessionEnded && (snapshot?.capabilities?.send === true || (
+ paneContent.provider === 'claude'
+ && Boolean(paneContent.sessionId)
+ && !isRestoring
+ && !hasRestoreFailure
+ && !['creating', 'starting', 'create-failed', 'exited'].includes(effectiveStatus)
+ ))
+ // Providers report capabilities.send=false WHILE BUSY — that must not
+ // disable the composer, or queueing becomes unreachable for codex and
+ // opencode (live-test finding). Disabled = no session, ended, or truly
+ // read-only when idle.
+ const composerDisabled = !paneContent.sessionId || sessionEnded || (!canSend && !isBusy)
useEffect(() => {
- if (hidden) return
+ if (!isActivePane) return
const frame = requestAnimationFrame(() => {
- if (isEditableTarget(document.activeElement)) return
+ const active = document.activeElement
+ if (active instanceof HTMLElement
+ && paneRootRef.current?.contains(active)
+ && isEditableTarget(active)) return
+ if (composerDisabled) {
+ paneRootRef.current?.focus()
+ return
+ }
composerRef.current?.focus()
})
return () => cancelAnimationFrame(frame)
- }, [effectiveStatus, hidden, paneContent.sessionId])
+ }, [isActivePane, composerDisabled])
// Fallback poll while the agent is (or claims to be) working: if a
// transport event is missed, the pane self-heals within a few seconds
@@ -1460,26 +1539,12 @@ export function FreshAgentView({
|| childThreads.length > 0
|| Boolean(codexReview)
|| Boolean(codexFork)
- // sessionEnded gates everything: a stale snapshot can still claim
- // capabilities.send after the provider process died.
- const canSend = !sessionEnded && (snapshot?.capabilities?.send === true || (
- paneContent.provider === 'claude'
- && Boolean(paneContent.sessionId)
- && !isRestoring
- && !hasRestoreFailure
- && !['creating', 'starting', 'create-failed', 'exited'].includes(effectiveStatus)
- ))
const canInterrupt = isBusy && (snapshot?.capabilities?.interrupt === true || (
paneContent.provider === 'claude'
&& Boolean(paneContent.sessionId)
&& ['connected', 'running', 'compacting'].includes(effectiveStatus)
))
const canFork = snapshot?.capabilities?.fork === true
- // Providers report capabilities.send=false WHILE BUSY — that must not
- // disable the composer, or queueing becomes unreachable for codex and
- // opencode (live-test finding). Disabled = no session, ended, or truly
- // read-only when idle.
- const composerDisabled = !paneContent.sessionId || sessionEnded || (!canSend && !isBusy)
const questionAgentLabel = getQuestionAgentLabel(paneContent, descriptor?.label)
const visibleRestoreFailure = paneContent.provider === 'claude'
? claudeSession?.restoreFailureMessage
@@ -1496,6 +1561,10 @@ export function FreshAgentView({
}
const handlePaneKeyDown = (event: ReactKeyboardEvent) => {
if (event.defaultPrevented) return
+ if (isTranscriptNavigationKey(event) && !isInteractiveTarget(event.target)) {
+ scrollTranscriptByKey(event, transcriptRef.current)
+ return
+ }
if (isEditableTarget(event.target)) return
if (!isPlainTextKey(event)) return
event.preventDefault()
@@ -1530,6 +1599,8 @@ export function FreshAgentView({
return (
{
@@ -1727,16 +1798,16 @@ export function FreshAgentView({
)
}, [
+ canSend,
claudeSession?.restoreFailureMessage,
activeStyle,
+ composerDisabled,
descriptor?.icon,
descriptor?.label,
effectiveStatus,
effectiveShowThinking,
effectiveShowTimecodes,
effectiveShowTools,
- hasRestoreFailure,
- hidden,
isBusy,
isRestoring,
loadError,
diff --git a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx
index 5d1e4b53..970a2a93 100644
--- a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx
+++ b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx
@@ -73,4 +73,71 @@ describe('FreshAgentItemCard', () => {
fireEvent.click(trigger)
expect(container.querySelector('[data-tool-output]')).toBeInTheDocument()
})
+
+ describe('tool notification polish (5kxd)', () => {
+ it('drops the vertical line from the tool block while keeping trigger padding', () => {
+ const { container } = render(
+
,
+ )
+ const toolBlock = container.querySelector('.fresh-agent-tool-block') as HTMLElement
+ expect(toolBlock).toBeTruthy()
+ expect(toolBlock.className).not.toContain('border-l-2')
+ expect(toolBlock.className).not.toContain('border-l-[')
+ const trigger = screen.getByRole('button', { name: 'Bash tool call' })
+ expect(trigger.className).toContain('px-2')
+ })
+
+ it('preserves error state on the tool block without the vertical line', () => {
+ const { container } = render(
+
,
+ )
+ const toolBlock = container.querySelector('.fresh-agent-tool-block') as HTMLElement
+ expect(toolBlock).toBeTruthy()
+ expect(toolBlock.className).not.toContain('border-l-')
+ expect(screen.getByLabelText('error')).toBeInTheDocument()
+ const summary = screen.getByText('(error)')
+ expect(summary.className).toContain('text-destructive')
+ })
+
+ it('drops the vertical line from the thinking disclosure', () => {
+ const { container } = render(
+
,
+ )
+ const disclosure = container.querySelector('.fresh-agent-thinking-details') as HTMLElement
+ expect(disclosure).toBeTruthy()
+ expect(disclosure.className).not.toContain('border-l-2')
+ expect(disclosure.className).not.toContain('border-l-[')
+ })
+
+ it('drops the vertical line from the tool result card', () => {
+ const { container } = render(
+
,
+ )
+ const card = container.querySelector('.fresh-agent-tool-result') as HTMLElement
+ expect(card).toBeTruthy()
+ expect(card.className).not.toContain('border-l-2')
+ expect(card.className).not.toContain('border-l-')
+ expect(card.className).toContain('px-2')
+ })
+ })
})
diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx
index 3c909518..7a756862 100644
--- a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx
+++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx
@@ -101,7 +101,7 @@ describe('FreshAgentTranscript', () => {
})
it('coalesces paired tool calls into the activity strip and expands details', () => {
- render(
+ const { container } = render(
{
content: 'README.md\nAGENTS.md',
isError: false,
},
+ {
+ id: 'tool-2',
+ kind: 'tool_use',
+ toolUseId: 'call-2',
+ name: 'Bash',
+ input: { command: 'find . -name "*.ts"', description: 'Find TypeScript files' },
+ },
+ {
+ id: 'result-2',
+ kind: 'tool_result',
+ toolUseId: 'call-2',
+ content: 'src/App.tsx',
+ isError: false,
+ },
],
},
]}
/>,
)
- expect(screen.getByRole('region', { name: 'Activity strip' })).toHaveTextContent('1 tool used')
+ expect(screen.getByRole('region', { name: 'Activity strip' })).toHaveTextContent('2 tools used')
fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' }))
- fireEvent.click(screen.getByRole('button', { name: 'Bash tool call' }))
+ expect(container.querySelector('[data-tool-input]')).not.toBeInTheDocument()
+ const toolButtons = screen.getAllByRole('button', { name: 'Bash tool call' })
+ expect(toolButtons).toHaveLength(2)
+ fireEvent.click(toolButtons[0])
expect(screen.getByText('find . -name "*.md"')).toBeInTheDocument()
})
@@ -749,6 +766,479 @@ describe('FreshAgentTranscript', () => {
expect(screen.queryByText(/hidden internals/)).not.toBeInTheDocument()
})
+ describe('tool notification polish (5kxd)', () => {
+ it('drops the vertical line from the activity summary while keeping left padding', () => {
+ const { container } = render(
+ ,
+ )
+ const summary = container.querySelector('.fresh-agent-activity-summary') as HTMLElement
+ expect(summary).toBeTruthy()
+ expect(summary.className).not.toContain('border-l-2')
+ expect(summary.className).not.toContain('border-l-[')
+ expect(summary.className).toContain('px-2')
+ })
+
+ it('expands a single-tool activity strip body in one click', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.querySelector('[data-tool-input]')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' }))
+ expect(container.querySelector('[data-tool-input]')).toHaveTextContent('echo hi')
+ expect(container.querySelector('[data-tool-output]')).toHaveTextContent('hi')
+ })
+
+ it('keeps multi-tool strip headers collapsed until individually expanded', () => {
+ const { container } = render(
+ ,
+ )
+ expect(screen.getByRole('region', { name: 'Activity strip' })).toHaveTextContent('2 tools used')
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' }))
+ expect(container.querySelector('[data-tool-input]')).not.toBeInTheDocument()
+ const toolButtons = screen.getAllByRole('button', { name: 'Bash tool call' })
+ expect(toolButtons).toHaveLength(2)
+ fireEvent.click(toolButtons[0])
+ expect(container.querySelector('[data-tool-input]')).toHaveTextContent('echo first')
+ expect(container.querySelectorAll('[data-tool-input]')).toHaveLength(1)
+ })
+
+ it('preserves error state on the activity strip without the vertical line', () => {
+ render(
+ ,
+ )
+ const summary = screen.getByRole('region', { name: 'Activity strip' }).querySelector('.fresh-agent-activity-summary') as HTMLElement
+ expect(summary).toBeTruthy()
+ expect(summary.className).not.toContain('border-l-')
+ expect(screen.getByLabelText('error')).toBeInTheDocument()
+ })
+
+ it('drops the vertical line from the thinking row in the activity strip', () => {
+ const { container } = render(
+ ,
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' }))
+ const thinkingRow = container.querySelector('.fresh-agent-thinking-row') as HTMLElement
+ expect(thinkingRow).toBeTruthy()
+ expect(thinkingRow.className).not.toContain('border-l-2')
+ expect(thinkingRow.className).not.toContain('border-l-[')
+ })
+
+ it('keeps the thinking row trigger left padding unchanged', () => {
+ const { container } = render(
+ ,
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' }))
+ const trigger = container.querySelector('.fresh-agent-thinking-trigger') as HTMLElement
+ expect(trigger).toBeTruthy()
+ expect(trigger.className).toContain('px-2')
+ })
+ })
+
+ describe('streaming height stability (jp70)', () => {
+ const thinkingOnly = (turnId: string, thinkId: string, text: string) => ({
+ id: turnId,
+ role: 'assistant' as const,
+ summary: 'thinking',
+ items: [{ id: thinkId, kind: 'thinking' as const, text }],
+ })
+
+ const withTool = (turnId: string, thinkId: string, text: string, toolId: string, callId: string) => ({
+ id: turnId,
+ role: 'assistant' as const,
+ summary: 'thinking + tool',
+ items: [
+ { id: thinkId, kind: 'thinking' as const, text },
+ { id: toolId, kind: 'tool_use' as const, toolUseId: callId, name: 'Bash', input: { command: 'true' } },
+ ],
+ })
+
+ it('keeps the streaming last turn even when all items are filtered out', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('article', { name: 'Assistant transcript turn' })).toBeInTheDocument()
+ })
+
+ it('renders a live activity strip placeholder when no displayable rows exist during streaming', () => {
+ render(
+ ,
+ )
+
+ const strip = screen.getByRole('region', { name: 'Activity strip' })
+ expect(strip).toBeInTheDocument()
+ expect(strip.className).toContain('my-0.5')
+ expect(screen.getByLabelText('running')).toBeInTheDocument()
+ })
+
+ it('keeps the live activity strip present across empty/non-empty displayRows transitions', () => {
+ const { rerender } = render(
+ ,
+ )
+
+ const assertStripPresent = () => {
+ const strip = screen.getByRole('region', { name: 'Activity strip' })
+ expect(strip).toBeInTheDocument()
+ expect(strip.className).toContain('my-0.5')
+ expect(screen.getByLabelText('running')).toBeInTheDocument()
+ }
+
+ assertStripPresent()
+
+ rerender(
+ ,
+ )
+ assertStripPresent()
+
+ rerender(
+ ,
+ )
+ assertStripPresent()
+
+ rerender(
+ ,
+ )
+ assertStripPresent()
+ })
+
+ it('does not show a second running indicator on an earlier turn when the streaming last turn has no displayable items', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getAllByLabelText('running')).toHaveLength(1)
+ const strips = screen.getAllByRole('region', { name: 'Activity strip' })
+ expect(strips).toHaveLength(2)
+ expect(strips[0]).toHaveTextContent('1 tool used')
+ })
+
+ it('drops a non-streaming turn when all items are filtered out', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByRole('article', { name: 'Assistant transcript turn' })).not.toBeInTheDocument()
+ })
+
+ it('does not resnap autoscroll when re-rendering with the same streaming items', () => {
+ let scrollHeight = 1000
+ const turn = thinkingOnly('turn-1', 'think-1', 'hidden reasoning')
+ const { container, rerender } = render(
+ ,
+ )
+ const scroller = container.querySelector('[data-context="fresh-agent-transcript"]') as HTMLDivElement
+ Object.defineProperty(scroller, 'clientHeight', { configurable: true, get: () => 200 })
+ Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => scrollHeight })
+ scroller.scrollTop = 1000
+ fireEvent.scroll(scroller)
+
+ expect(scroller.scrollTop).toBe(1000)
+
+ scrollHeight = 1200
+ rerender()
+
+ expect(scroller.scrollTop).toBe(1000)
+ })
+ })
+
+ describe('user turn glom chip', () => {
+ const TRANSCRIPT = [
+ {
+ id: 'u1',
+ role: 'user' as const,
+ summary: 'First user message here',
+ items: [{ id: 'i1', kind: 'text' as const, text: 'First user message here' }],
+ },
+ {
+ id: 'a1',
+ role: 'assistant' as const,
+ summary: 'reply 1',
+ items: [{ id: 'i2', kind: 'text' as const, text: 'A'.repeat(200) }],
+ },
+ {
+ id: 'u2',
+ role: 'user' as const,
+ summary: 'Second user message here',
+ items: [{ id: 'i3', kind: 'text' as const, text: 'Second user message here' }],
+ },
+ {
+ id: 'a2',
+ role: 'assistant' as const,
+ summary: 'reply 2',
+ items: [{ id: 'i4', kind: 'text' as const, text: 'B'.repeat(200) }],
+ },
+ {
+ id: 'u3',
+ role: 'user' as const,
+ summary: 'Third user message here',
+ items: [{ id: 'i5', kind: 'text' as const, text: 'Third user message here' }],
+ },
+ {
+ id: 'a3',
+ role: 'assistant' as const,
+ summary: 'reply 3',
+ items: [{ id: 'i6', kind: 'text' as const, text: 'C'.repeat(200) }],
+ },
+ ]
+
+ function mockScroll(scroller: HTMLElement, scrollTop: number, scrollHeight: number, clientHeight: number) {
+ Object.defineProperty(scroller, 'clientHeight', { configurable: true, get: () => clientHeight })
+ Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => scrollHeight })
+ scroller.scrollTop = scrollTop
+ }
+
+ function mockRect(el: Element, top: number) {
+ el.getBoundingClientRect = () => ({
+ top,
+ bottom: top + 50,
+ left: 0,
+ right: 800,
+ width: 800,
+ height: 50,
+ x: 0,
+ y: top,
+ toJSON: () => ({}),
+ })
+ }
+
+ function setupScrolledTranscript() {
+ const utils = render()
+ const scroller = utils.container.querySelector('[data-context="fresh-agent-transcript"]') as HTMLDivElement
+ mockScroll(scroller, 400, 1000, 200)
+ const userTurns = utils.container.querySelectorAll('[data-turn-role="user"]')
+ mockRect(scroller, 0)
+ mockRect(userTurns[0], -400)
+ mockRect(userTurns[1], -100)
+ mockRect(userTurns[2], 50)
+ fireEvent.scroll(scroller)
+ return { ...utils, scroller, userTurns }
+ }
+
+ it('shows the most-recent offscreen-above user turn when scrolled', () => {
+ setupScrolledTranscript()
+
+ const chip = screen.getByRole('button', { name: /Jump to your message/ })
+ expect(chip).toBeInTheDocument()
+ expect(chip).toHaveTextContent('Second user message here')
+ expect(chip).toHaveAttribute('title', 'Second user message here')
+ const chipText = chip.querySelector('span')
+ expect(chipText).toHaveClass('truncate')
+ })
+
+ it('does not render the chip when no user turns are above the viewport', () => {
+ const { container } = render()
+ const scroller = container.querySelector('[data-context="fresh-agent-transcript"]') as HTMLDivElement
+ mockScroll(scroller, 0, 1000, 200)
+ const userTurns = container.querySelectorAll('[data-turn-role="user"]')
+ mockRect(scroller, 0)
+ mockRect(userTurns[0], 10)
+ mockRect(userTurns[1], 100)
+ mockRect(userTurns[2], 200)
+ fireEvent.scroll(scroller)
+
+ expect(screen.queryByRole('button', { name: /Jump to your message/ })).not.toBeInTheDocument()
+ })
+
+ it('clicking the chip scrolls the target user turn into view and leaves autoscroll paused', () => {
+ const { userTurns } = setupScrolledTranscript()
+ const scrollIntoViewSpy = vi.fn()
+ userTurns[1].scrollIntoView = scrollIntoViewSpy
+
+ const chip = screen.getByRole('button', { name: /Jump to your message/ })
+ fireEvent.click(chip)
+
+ expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: 'start' })
+ expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeInTheDocument()
+ })
+
+ it('does not resnap to bottom when new agent output arrives after clicking the chip', () => {
+ const { scroller, rerender: rerenderFn } = setupScrolledTranscript()
+ const chip = screen.getByRole('button', { name: /Jump to your message/ })
+ fireEvent.click(chip)
+
+ const scrollTopBefore = scroller.scrollTop
+
+ rerenderFn(
+ ,
+ )
+
+ expect(scroller.scrollTop).toBe(scrollTopBefore)
+ })
+
+ it('is a button with aria-label containing the full text and a title tooltip', () => {
+ setupScrolledTranscript()
+
+ const chip = screen.getByRole('button', { name: /Jump to your message/ })
+ expect(chip.tagName).toBe('BUTTON')
+ expect(chip).toHaveAttribute('aria-label', 'Jump to your message: Second user message here')
+ expect(chip).toHaveAttribute('title', 'Second user message here')
+ })
+
+ it('coexists with the scroll-to-bottom button without overlapping', () => {
+ setupScrolledTranscript()
+
+ const chip = screen.getByRole('button', { name: /Jump to your message/ })
+ const scrollBottom = screen.getByRole('button', { name: 'Scroll to bottom' })
+ expect(chip).toBeInTheDocument()
+ expect(scrollBottom).toBeInTheDocument()
+ expect(chip.className).toContain('top-0')
+ expect(scrollBottom.className).toContain('bottom-')
+ })
+
+ it('recomputes the glom target when transcript content changes', () => {
+ const { container, rerender: rerenderFn } = render()
+ const scroller = container.querySelector('[data-context="fresh-agent-transcript"]') as HTMLDivElement
+ mockScroll(scroller, 0, 1000, 200)
+ const userTurns = container.querySelectorAll('[data-turn-role="user"]')
+ mockRect(scroller, 0)
+ mockRect(userTurns[0], 10)
+ mockRect(userTurns[1], 100)
+ mockRect(userTurns[2], 200)
+ fireEvent.scroll(scroller)
+ expect(screen.queryByRole('button', { name: /Jump to your message/ })).not.toBeInTheDocument()
+
+ mockRect(userTurns[0], -100)
+ rerenderFn()
+
+ const chip = screen.getByRole('button', { name: /Jump to your message/ })
+ expect(chip).toHaveTextContent('First user message here')
+ })
+ })
+
describe('turn actions', () => {
const TURNS = [
{
diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx
index 0f5a2dd6..dee80792 100644
--- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx
+++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
-import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react'
+import { render, screen, waitFor, fireEvent, createEvent, cleanup, act } from '@testing-library/react'
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import panesReducer from '@/store/panesSlice'
@@ -8,7 +8,7 @@ import freshAgentReducer, { sessionInit, setSessionStatus } from '@/store/freshA
import tabsReducer from '@/store/tabsSlice'
import { FreshAgentView } from '@/components/fresh-agent/FreshAgentView'
import { FreshAgentSettingsButton } from '@/components/fresh-agent/FreshAgentSettingsButton'
-import { initLayout, requestPaneRefresh, updatePaneContent, updatePaneTitle } from '@/store/panesSlice'
+import { initLayout, requestPaneRefresh, setActivePane, updatePaneContent, updatePaneTitle } from '@/store/panesSlice'
import { useAppSelector } from '@/store/hooks'
import { updateTab } from '@/store/tabsSlice'
import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws'
@@ -184,6 +184,9 @@ beforeEach(() => {
wsMock.send.mockReset()
wsMock.onMessage.mockReset()
wsMock.onMessage.mockImplementation(() => () => {})
+ window.sessionStorage.clear()
+ window.localStorage.removeItem('fresh-agent-prompt-history:freshcodex')
+ window.localStorage.removeItem('fresh-agent-prompt-history:freshclaude')
apiMock.getFreshAgentThreadSnapshot.mockReset()
apiMock.getFreshAgentModelCapabilities.mockReset()
apiMock.post.mockReset()
@@ -4138,4 +4141,240 @@ describe('FreshAgentView transcript font size', () => {
expect(root.style.getPropertyValue('--fresh-transcript-font-size')).toBe('20px')
})
+
+ describe('transcript keyboard scroll (faz3)', () => {
+ async function setupScrollablePane(initialScrollTop = 500) {
+ const store = createStore()
+ apiMock.getFreshAgentThreadSnapshot.mockResolvedValueOnce({
+ status: 'idle',
+ capabilities: { send: true, interrupt: true, fork: false },
+ turns: [
+ { id: 'turn-0', role: 'user', items: [{ id: 'item-0', kind: 'text', text: 'User message' }] },
+ { id: 'turn-1', role: 'assistant', items: [{ id: 'item-1', kind: 'text', text: 'Assistant reply' }] },
+ ],
+ })
+ render(
+
+
+ ,
+ )
+ await waitFor(() => expect(screen.getByText('Assistant reply')).toBeInTheDocument())
+ const root = document.querySelector('[data-context="fresh-agent"]') as HTMLElement
+ const scroller = document.querySelector('[data-context="fresh-agent-transcript"]') as HTMLDivElement
+ Object.defineProperty(scroller, 'clientHeight', { configurable: true, get: () => 200 })
+ Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => 1000 })
+ scroller.scrollTop = initialScrollTop
+ fireEvent.scroll(scroller)
+ return { root, scroller }
+ }
+
+ it('scrolls down by one line on ArrowDown when the pane root has focus', async () => {
+ const { root, scroller } = await setupScrollablePane(500)
+ const event = createEvent.keyDown(root, { key: 'ArrowDown' })
+ fireEvent(root, event)
+ expect(event.defaultPrevented).toBe(true)
+ expect(scroller.scrollTop).toBe(540)
+ })
+
+ it('scrolls up by one line on ArrowUp when the pane root has focus', async () => {
+ const { root, scroller } = await setupScrollablePane(500)
+ const event = createEvent.keyDown(root, { key: 'ArrowUp' })
+ fireEvent(root, event)
+ expect(event.defaultPrevented).toBe(true)
+ expect(scroller.scrollTop).toBe(460)
+ })
+
+ it('scrolls down by one page on PageDown when the pane root has focus', async () => {
+ const { root, scroller } = await setupScrollablePane(100)
+ const event = createEvent.keyDown(root, { key: 'PageDown' })
+ fireEvent(root, event)
+ expect(event.defaultPrevented).toBe(true)
+ expect(scroller.scrollTop).toBe(260)
+ })
+
+ it('scrolls up by one page on PageUp when the pane root has focus', async () => {
+ const { root, scroller } = await setupScrollablePane(500)
+ const event = createEvent.keyDown(root, { key: 'PageUp' })
+ fireEvent(root, event)
+ expect(event.defaultPrevented).toBe(true)
+ expect(scroller.scrollTop).toBe(340)
+ })
+
+ it('jumps to top on Home when the pane root has focus', async () => {
+ const { root, scroller } = await setupScrollablePane(500)
+ const event = createEvent.keyDown(root, { key: 'Home' })
+ fireEvent(root, event)
+ expect(event.defaultPrevented).toBe(true)
+ expect(scroller.scrollTop).toBe(0)
+ })
+
+ it('jumps to bottom on End when the pane root has focus', async () => {
+ const { root, scroller } = await setupScrollablePane(500)
+ const event = createEvent.keyDown(root, { key: 'End' })
+ fireEvent(root, event)
+ expect(event.defaultPrevented).toBe(true)
+ expect(scroller.scrollTop).toBe(1000)
+ })
+
+ it('does not scroll or preventDefault when the composer textarea has focus', async () => {
+ const { scroller } = await setupScrollablePane(500)
+ const textbox = screen.getByRole('textbox', { name: 'Chat message input' })
+ const before = scroller.scrollTop
+ for (const key of ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Home', 'End']) {
+ const event = createEvent.keyDown(textbox, { key })
+ fireEvent(textbox, event)
+ expect(event.defaultPrevented).toBe(false)
+ expect(scroller.scrollTop).toBe(before)
+ }
+ })
+
+ it('dismisses the scroll-to-bottom button after pressing End', async () => {
+ const { root } = await setupScrollablePane(500)
+ expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeInTheDocument()
+ fireEvent(root, createEvent.keyDown(root, { key: 'End' }))
+ await waitFor(() => {
+ expect(screen.queryByRole('button', { name: 'Scroll to bottom' })).not.toBeInTheDocument()
+ })
+ })
+
+ it('shows the scroll-to-bottom button after pressing Home', async () => {
+ const { root } = await setupScrollablePane(800)
+ expect(screen.queryByRole('button', { name: 'Scroll to bottom' })).not.toBeInTheDocument()
+ fireEvent(root, createEvent.keyDown(root, { key: 'Home' }))
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeInTheDocument()
+ })
+ })
+
+ it('shows the scroll-to-bottom button after pressing PageUp', async () => {
+ const { root } = await setupScrollablePane(800)
+ expect(screen.queryByRole('button', { name: 'Scroll to bottom' })).not.toBeInTheDocument()
+ fireEvent(root, createEvent.keyDown(root, { key: 'PageUp' }))
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeInTheDocument()
+ })
+ })
+
+ it('does not regress the plain-text key funnel into the composer', async () => {
+ const { root } = await setupScrollablePane(500)
+ const textbox = screen.getByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement
+ fireEvent(root, createEvent.keyDown(root, { key: 'h' }))
+ expect(textbox.value).toBe('h')
+ })
+ })
+
+ describe('composer focus on pane activation (0bc6)', () => {
+ async function flushFrames() {
+ await act(async () => {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()))
+ })
+ }
+
+ function renderFocusPane(options?: { sessionId?: string; status?: string }) {
+ const store = createStore()
+ const sessionId = options && 'sessionId' in options ? options.sessionId : 'thread-focus-0bc6'
+ render(
+
+
+ ,
+ )
+ return { store }
+ }
+
+ it('focuses the composer exactly once when the pane becomes the active pane of the active tab', async () => {
+ const { store } = renderFocusPane()
+ const textbox = await screen.findByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement
+ await waitFor(() => expect(textbox).not.toBeDisabled())
+ await flushFrames()
+ const focusSpy = vi.spyOn(textbox, 'focus')
+
+ act(() => {
+ store.dispatch(setActivePane({ tabId: 'tab-1', paneId: 'pane-1' }))
+ })
+
+ await waitFor(() => expect(focusSpy).toHaveBeenCalledTimes(1))
+ expect(document.activeElement).toBe(textbox)
+ })
+
+ it('does not re-focus the composer when it already has focus on activation', async () => {
+ const { store } = renderFocusPane()
+ const textbox = await screen.findByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement
+ await waitFor(() => expect(textbox).not.toBeDisabled())
+ act(() => {
+ store.dispatch(setActivePane({ tabId: 'tab-1', paneId: 'pane-1' }))
+ })
+ await waitFor(() => expect(document.activeElement).toBe(textbox))
+
+ const focusSpy = vi.spyOn(textbox, 'focus')
+ act(() => {
+ store.dispatch(setActivePane({ tabId: 'tab-1', paneId: 'pane-other' }))
+ })
+ act(() => {
+ store.dispatch(setActivePane({ tabId: 'tab-1', paneId: 'pane-1' }))
+ })
+ await flushFrames()
+
+ expect(focusSpy).not.toHaveBeenCalled()
+ expect(document.activeElement).toBe(textbox)
+ })
+
+ it('does not steal focus from another editable element inside the pane on activation', async () => {
+ const { store } = renderFocusPane()
+ const textbox = await screen.findByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement
+ await waitFor(() => expect(textbox).not.toBeDisabled())
+ const root = document.querySelector('[data-context="fresh-agent"]') as HTMLElement
+ const other = document.createElement('input')
+ other.setAttribute('aria-label', 'Other editable')
+ root.appendChild(other)
+ other.focus()
+ expect(document.activeElement).toBe(other)
+
+ const focusSpy = vi.spyOn(textbox, 'focus')
+ act(() => {
+ store.dispatch(setActivePane({ tabId: 'tab-1', paneId: 'pane-1' }))
+ })
+ await flushFrames()
+
+ expect(focusSpy).not.toHaveBeenCalled()
+ expect(document.activeElement).toBe(other)
+ root.removeChild(other)
+ })
+
+ it('leaves focus on the pane root when the composer is disabled on activation', async () => {
+ const { store } = renderFocusPane({ sessionId: undefined, status: 'creating' })
+ const root = await waitFor(() => document.querySelector('[data-context="fresh-agent"]') as HTMLElement)
+ const textbox = screen.getByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement
+ expect(textbox).toBeDisabled()
+ const focusSpy = vi.spyOn(textbox, 'focus')
+
+ act(() => {
+ store.dispatch(setActivePane({ tabId: 'tab-1', paneId: 'pane-1' }))
+ })
+
+ await waitFor(() => expect(document.activeElement).toBe(root))
+ expect(focusSpy).not.toHaveBeenCalled()
+ })
+ })
})