diff --git a/src/components/fresh-agent/FreshAgentItemCard.tsx b/src/components/fresh-agent/FreshAgentItemCard.tsx index 5d299d8c..8ceb3b98 100644 --- a/src/components/fresh-agent/FreshAgentItemCard.tsx +++ b/src/components/fresh-agent/FreshAgentItemCard.tsx @@ -78,8 +78,8 @@ export function FreshAgentToolBlock({ return (
)} @@ -415,6 +444,8 @@ function FreshAgentTurnArticle({ continuation, liveActivityBlockId, displayOptions, + isStreamingLastTurn, + index, }: { turn: FreshAgentTurn actions: TurnActionProps @@ -425,6 +456,8 @@ function FreshAgentTurnArticle({ continuation: boolean liveActivityBlockId: string | null displayOptions: TranscriptDisplayOptions + isStreamingLastTurn: boolean + index: number }) { const isUser = turn.role === 'user' const blocks = buildBlocks(turn.items, displayOptions) @@ -446,6 +479,7 @@ function FreshAgentTurnArticle({ continuation && 'mt-1.5', )} data-turn-role={turn.role} + data-turn-index={index} data-turn-continuation={continuation ? 'true' : 'false'} aria-label={`${turnLabel} transcript turn`} onContextMenu={(event) => { @@ -502,23 +536,30 @@ function FreshAgentTurnArticle({ // showed literal backticks (live-test finding) — render markdown. )} + {isStreamingLastTurn && blocks.length === 0 ? ( + + ) : null} ) } -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 ? ( + + ) : 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() + }) + }) })