diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 2d372000e..5a67811c9 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -192,7 +192,7 @@ runtime cost control, and shared AI transport surfaces. is typed end to end. The transcript viewport is part of the same live activity contract. The referenced OpenCode source at fetched `origin/dev` commit - `4519a1da329c1a4fc384054e7203ba7d06928205` forwards reducer commits into + `1025540fcc2a69609a0131a7168300205656d728` forwards reducer commits into terminal scrollback in `packages/opencode/src/cli/cmd/run/stream.ts` (lines 140-145) and renders bottom-sticky activity in @@ -201,7 +201,10 @@ runtime cost control, and shared AI transport surfaces. by tracking whether the browser transcript is pinned to the live bottom before streamed content or tool rows grow, continuing to follow active output while pinned, and preserving the operator's scroll position after - they intentionally scroll away from live activity. + they intentionally scroll away from live activity. When the transcript is + unpinned while messages exist, the drawer must expose an accessible + jump-to-latest command that returns the operator to the live bottom without + requiring a manual scroll gesture. Composer prompt history is also drawer-local chat-runtime state: the drawer may persist a bounded local history of submitted prompt text and structured mentions for ArrowUp/ArrowDown recall, but that history must not persist or diff --git a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx index 7abe6a790..e036d9204 100644 --- a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx +++ b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx @@ -1,4 +1,5 @@ import { Component, Show, For, createEffect, createMemo, createSignal } from 'solid-js'; +import ArrowDownIcon from 'lucide-solid/icons/arrow-down'; import { MessageItem } from './MessageItem'; import type { ChatSession } from '@/api/aiChat'; import type { QueuedFollowUp } from './hooks/useChat'; @@ -60,6 +61,11 @@ export const ChatMessages: Component = (props) => { setIsPinnedToBottom(isContainerNearBottom()); }; + const jumpToLatest = () => { + setIsPinnedToBottom(true); + messagesEndRef?.scrollIntoView({ behavior: 'smooth' }); + }; + const textActivityFingerprint = (value?: string) => value ? `${value.length}:${value.slice(-32)}` : '0:'; @@ -191,93 +197,106 @@ export const ChatMessages: Component = (props) => { }); return ( -
- 0 && props.onLoadSession} +
+
-
-
Recent sessions
-
- - {(session) => { - const handoffLabel = () => formatSessionHandoffLabel(session); - return ( - - ); - }} - -
-
+ 0 && props.onLoadSession} + > +
+
Recent sessions
+
+ + {(session) => { + const handoffLabel = () => formatSessionHandoffLabel(session); + return ( + + ); + }} + +
+
+
+ + {/* Messages */} + + {(message) => { + const queuedMeta = createMemo(() => queuedFollowUpMetaByMessageId().get(message.id)); + return ( + props.onApprove(message.id, approval)} + onSkip={(toolId) => props.onSkip(message.id, toolId)} + onAnswerQuestion={(question, answers) => + props.onAnswerQuestion(message.id, question, answers) + } + onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)} + onRetry={props.onRetry} + onChangeModel={props.onChangeModel} + getModelRouteLabel={props.getModelRouteLabel} + modelRouteAlternative={props.getModelRouteAlternative?.(message)} + onUseModelRoute={props.onUseModelRoute} + queuedPosition={queuedMeta()?.position} + queuedCount={queuedMeta()?.count} + onEditQueued={ + queuedMeta() && props.onEditQueuedFollowUp + ? () => { + const meta = queuedMeta(); + if (meta) props.onEditQueuedFollowUp?.(meta.id); + } + : undefined + } + onCancelQueued={ + queuedMeta() && props.onCancelQueuedFollowUp + ? () => { + const meta = queuedMeta(); + if (meta) props.onCancelQueuedFollowUp?.(meta.id); + } + : undefined + } + /> + ); + }} + + + {/* Scroll anchor */} +
+
+ 0 && !isPinnedToBottom()}> + - - {/* Messages */} - - {(message) => { - const queuedMeta = createMemo(() => queuedFollowUpMetaByMessageId().get(message.id)); - return ( - props.onApprove(message.id, approval)} - onSkip={(toolId) => props.onSkip(message.id, toolId)} - onAnswerQuestion={(question, answers) => - props.onAnswerQuestion(message.id, question, answers) - } - onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)} - onRetry={props.onRetry} - onChangeModel={props.onChangeModel} - getModelRouteLabel={props.getModelRouteLabel} - modelRouteAlternative={props.getModelRouteAlternative?.(message)} - onUseModelRoute={props.onUseModelRoute} - queuedPosition={queuedMeta()?.position} - queuedCount={queuedMeta()?.count} - onEditQueued={ - queuedMeta() && props.onEditQueuedFollowUp - ? () => { - const meta = queuedMeta(); - if (meta) props.onEditQueuedFollowUp?.(meta.id); - } - : undefined - } - onCancelQueued={ - queuedMeta() && props.onCancelQueuedFollowUp - ? () => { - const meta = queuedMeta(); - if (meta) props.onCancelQueuedFollowUp?.(meta.id); - } - : undefined - } - /> - ); - }} - - - {/* Scroll anchor */} -
); }; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx index b27e0e2c9..2ff6a0575 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx @@ -599,6 +599,55 @@ describe('ChatMessages', () => { expect(scrollIntoView).not.toHaveBeenCalled(); }); + it('shows a jump to latest control when the user scrolls away from messages', () => { + render(() => ); + const scrollContainer = screen.getByTestId('assistant-message-list'); + + expect( + screen.queryByRole('button', { name: 'Jump to latest Assistant message' }), + ).not.toBeInTheDocument(); + + setScrollMetrics(scrollContainer, { + scrollTop: 100, + scrollHeight: 1000, + clientHeight: 200, + }); + fireEvent.scroll(scrollContainer); + + expect( + screen.getByRole('button', { name: 'Jump to latest Assistant message' }), + ).toBeInTheDocument(); + }); + + it('jumps back to live output and hides the control when selected', () => { + render(() => ); + const scrollContainer = screen.getByTestId('assistant-message-list'); + const scrollIntoView = Element.prototype.scrollIntoView as ReturnType; + + setScrollMetrics(scrollContainer, { + scrollTop: 100, + scrollHeight: 1000, + clientHeight: 200, + }); + fireEvent.scroll(scrollContainer); + scrollIntoView.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'Jump to latest Assistant message' })); + + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); + expect( + screen.queryByRole('button', { name: 'Jump to latest Assistant message' }), + ).not.toBeInTheDocument(); + }); + + it('does not show the jump to latest control in an empty transcript', () => { + render(() => ); + + expect( + screen.queryByRole('button', { name: 'Jump to latest Assistant message' }), + ).not.toBeInTheDocument(); + }); + it('does not call scrollIntoView when messages list is empty', () => { // Reset the mock to clear any prior calls (Element.prototype.scrollIntoView as ReturnType).mockClear(); @@ -613,8 +662,10 @@ describe('ChatMessages', () => { it('renders the scrollable container with correct classes', () => { const { container } = render(() => ); - const scrollContainer = container.firstElementChild; - expect(scrollContainer).toHaveClass('flex-1', 'overflow-y-auto'); + const viewport = container.firstElementChild; + const scrollContainer = screen.getByTestId('assistant-message-list'); + expect(viewport).toHaveClass('relative', 'flex-1', 'min-h-0'); + expect(scrollContainer).toHaveClass('h-full', 'overflow-y-auto'); expect(scrollContainer).toHaveAttribute('data-testid', 'assistant-message-list'); }); });