mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Add Assistant jump to latest transcript control
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user