Add Assistant jump to latest transcript control

This commit is contained in:
rcourtman
2026-06-07 03:08:06 +01:00
parent 9d7b02e18d
commit 2bbdb89b34
3 changed files with 162 additions and 89 deletions
@@ -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
@@ -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<ChatMessagesProps> = (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<ChatMessagesProps> = (props) => {
});
return (
<div
ref={containerRef}
class="flex-1 overflow-y-auto px-4 py-3 bg-surface"
data-testid="assistant-message-list"
onScroll={updatePinnedToBottom}
>
<Show
when={props.messages.length === 0 && recentSessions().length > 0 && props.onLoadSession}
<div class="relative flex-1 min-h-0 bg-surface">
<div
ref={containerRef}
class="h-full overflow-y-auto px-4 py-3 bg-surface"
data-testid="assistant-message-list"
onScroll={updatePinnedToBottom}
>
<section class="mb-3 w-full" aria-label="Recent Assistant sessions">
<div class="mb-2 text-[11px] font-semibold uppercase text-muted">Recent sessions</div>
<div class="space-y-1.5">
<For each={recentSessions()}>
{(session) => {
const handoffLabel = () => formatSessionHandoffLabel(session);
return (
<button
type="button"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-left transition-colors hover:border-blue-300 hover:bg-surface-alt focus:outline-none focus:ring-2 focus:ring-blue-500/30"
onClick={() => props.onLoadSession?.(session.id)}
aria-label={`Resume ${session.title || 'Untitled Assistant session'}`}
>
<div class="truncate text-sm font-medium text-base-content">
{session.title || 'Untitled'}
</div>
<div class="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-muted">
<span>{formatSessionMessageCount(session.message_count)}</span>
<Show when={handoffLabel()}>
{(label) => (
<>
<span aria-hidden="true">/</span>
<span class="truncate">{label()}</span>
</>
)}
</Show>
</div>
</button>
);
}}
</For>
</div>
</section>
<Show
when={props.messages.length === 0 && recentSessions().length > 0 && props.onLoadSession}
>
<section class="mb-3 w-full" aria-label="Recent Assistant sessions">
<div class="mb-2 text-[11px] font-semibold uppercase text-muted">Recent sessions</div>
<div class="space-y-1.5">
<For each={recentSessions()}>
{(session) => {
const handoffLabel = () => formatSessionHandoffLabel(session);
return (
<button
type="button"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-left transition-colors hover:border-blue-300 hover:bg-surface-alt focus:outline-none focus:ring-2 focus:ring-blue-500/30"
onClick={() => props.onLoadSession?.(session.id)}
aria-label={`Resume ${session.title || 'Untitled Assistant session'}`}
>
<div class="truncate text-sm font-medium text-base-content">
{session.title || 'Untitled'}
</div>
<div class="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-muted">
<span>{formatSessionMessageCount(session.message_count)}</span>
<Show when={handoffLabel()}>
{(label) => (
<>
<span aria-hidden="true">/</span>
<span class="truncate">{label()}</span>
</>
)}
</Show>
</div>
</button>
);
}}
</For>
</div>
</section>
</Show>
{/* Messages */}
<For each={props.messages}>
{(message) => {
const queuedMeta = createMemo(() => queuedFollowUpMetaByMessageId().get(message.id));
return (
<MessageItem
message={message}
onApprove={(approval) => 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
}
/>
);
}}
</For>
{/* Scroll anchor */}
<div ref={messagesEndRef} class="h-1" />
</div>
<Show when={props.messages.length > 0 && !isPinnedToBottom()}>
<button
type="button"
class="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content shadow-lg transition-colors hover:bg-surface-alt focus:outline-none focus:ring-2 focus:ring-blue-500/40"
onClick={jumpToLatest}
aria-label="Jump to latest Assistant message"
>
<ArrowDownIcon class="h-3.5 w-3.5" aria-hidden="true" />
<span>Latest</span>
</button>
</Show>
{/* Messages */}
<For each={props.messages}>
{(message) => {
const queuedMeta = createMemo(() => queuedFollowUpMetaByMessageId().get(message.id));
return (
<MessageItem
message={message}
onApprove={(approval) => 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
}
/>
);
}}
</For>
{/* Scroll anchor */}
<div ref={messagesEndRef} class="h-1" />
</div>
);
};
@@ -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(() => <ChatMessages messages={[makeMessage({ id: 'msg-1' })]} {...makeHandlers()} />);
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(() => <ChatMessages messages={[makeMessage({ id: 'msg-1' })]} {...makeHandlers()} />);
const scrollContainer = screen.getByTestId('assistant-message-list');
const scrollIntoView = Element.prototype.scrollIntoView as ReturnType<typeof vi.fn>;
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(() => <ChatMessages messages={[]} {...makeHandlers()} />);
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<typeof vi.fn>).mockClear();
@@ -613,8 +662,10 @@ describe('ChatMessages', () => {
it('renders the scrollable container with correct classes', () => {
const { container } = render(() => <ChatMessages messages={[]} {...makeHandlers()} />);
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');
});
});