Fix chat scroll jumping

This commit is contained in:
George Pickett
2026-03-10 12:49:56 -07:00
parent d9b43c7826
commit 607e934a05
10 changed files with 283 additions and 89 deletions
+2
View File
@@ -12,6 +12,7 @@
# testing
/coverage
/.tmp-vitest/
# next.js
/.next/
@@ -23,6 +24,7 @@
# misc
.DS_Store
*.pem
/.worktrees/
# debug
npm-debug.log*
+12
View File
@@ -312,6 +312,7 @@
body {
@apply bg-background text-foreground;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
letter-spacing: var(--tracking-normal);
font-family: var(--font-sans), sans-serif;
@@ -982,6 +983,8 @@ textarea:focus::-webkit-scrollbar {
border: 1px solid color-mix(in oklch, var(--border) 72%, transparent);
border-radius: var(--radius-card);
background: var(--chat-surface-bg);
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.dark .ui-chat-scroll {
@@ -1001,12 +1004,14 @@ textarea:focus::-webkit-scrollbar {
}
.ui-chat-user-card {
max-width: 100%;
border: 1px solid var(--chat-user-border);
background: var(--chat-user-bg);
box-shadow: 0 1px 3px color-mix(in oklch, var(--foreground) 8%, transparent);
}
.ui-chat-assistant-card {
max-width: 100%;
border: 1px solid var(--chat-assistant-border);
border-radius: var(--radius-small);
background: var(--chat-assistant-bg);
@@ -1313,6 +1318,13 @@ textarea:focus::-webkit-scrollbar {
box-shadow: none !important;
}
@media (max-width: 768px) {
.chat-composer-input,
[data-agent-panel] .ui-input.ui-control-important {
font-size: 16px !important;
}
}
@keyframes fadeUp {
from {
opacity: 0;
+8 -8
View File
@@ -1372,8 +1372,8 @@ const AgentStudioPage = () => {
if (!agentsLoadedOnce && !coreConnected && (!didAttemptGatewayConnect || gatewayConnecting)) {
return (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="flex min-h-screen items-center justify-center px-6">
<div className="relative min-h-dvh w-screen overflow-hidden bg-background">
<div className="flex min-h-dvh items-center justify-center px-6">
<div className="glass-panel ui-panel w-full max-w-md px-6 py-6 text-center">
<div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
OpenClaw Studio
@@ -1389,8 +1389,8 @@ const AgentStudioPage = () => {
if (!coreConnected && !agentsLoadedOnce && didAttemptGatewayConnect) {
return (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="relative z-10 flex h-screen flex-col">
<div className="relative min-h-dvh w-screen overflow-hidden bg-background">
<div className="relative z-10 flex h-dvh flex-col">
<HeaderBar
status={gatewayStatus}
onConnectionSettings={() => setShowConnectionPanel(true)}
@@ -1438,8 +1438,8 @@ const AgentStudioPage = () => {
if (coreConnected && !agentsLoadedOnce) {
return (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="flex min-h-screen items-center justify-center px-6">
<div className="relative min-h-dvh w-screen overflow-hidden bg-background">
<div className="flex min-h-dvh items-center justify-center px-6">
<div className="glass-panel ui-panel w-full max-w-md px-6 py-6 text-center">
<div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
OpenClaw Studio
@@ -1452,7 +1452,7 @@ const AgentStudioPage = () => {
}
return (
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="relative min-h-dvh w-screen overflow-hidden bg-background">
{state.loading ? (
<div className="pointer-events-none fixed bottom-4 left-0 right-0 z-50 flex justify-center px-3">
<div className="glass-panel ui-card px-6 py-3 font-mono text-[11px] tracking-[0.08em] text-muted-foreground">
@@ -1460,7 +1460,7 @@ const AgentStudioPage = () => {
</div>
</div>
) : null}
<div className="relative z-10 flex h-screen flex-col">
<div className="relative z-10 flex h-dvh flex-col">
<HeaderBar
status={gatewayStatus}
onConnectionSettings={() => setShowConnectionPanel(true)}
+16 -1
View File
@@ -107,6 +107,7 @@
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
font-size: 0.82em;
white-space: break-spaces;
overflow-wrap: anywhere;
word-break: break-word;
background: color-mix(in oklch, var(--surface-3) 92%, transparent);
@@ -116,6 +117,8 @@
}
.agent-markdown pre {
max-width: 100%;
min-width: 0;
background: color-mix(in oklch, var(--surface-2) 94%, transparent);
border-radius: 0.4rem;
padding: 0.75rem 0.85rem;
@@ -124,6 +127,13 @@
line-height: 1.64;
}
.agent-markdown pre,
.agent-markdown pre code {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.agent-markdown pre code {
background: transparent;
padding: 0;
@@ -145,7 +155,12 @@
}
.agent-markdown table {
width: 100%;
display: block;
width: max-content;
min-width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-collapse: collapse;
font-size: 0.85em;
}
+121 -66
View File
@@ -14,7 +14,7 @@ import {
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, ChevronRight, Clock, Cog, Pencil, Shuffle, Trash2, X } from "lucide-react";
import { Check, ChevronRight, Clock, Cog, Maximize2, Pencil, Shuffle, Trash2, X } from "lucide-react";
import type { GatewayModelChoice } from "@/lib/gateway/models";
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
@@ -665,7 +665,6 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
}>;
}) {
const chatRef = useRef<HTMLDivElement | null>(null);
const chatBottomRef = useRef<HTMLDivElement | null>(null);
const scrollFrameRef = useRef<number | null>(null);
const pinnedRef = useRef(true);
const [isPinned, setIsPinned] = useState(true);
@@ -673,12 +672,9 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
const [nowMs, setNowMs] = useState<number | null>(null);
const scrollChatToBottom = useCallback(() => {
if (!chatRef.current) return;
if (chatBottomRef.current) {
chatBottomRef.current.scrollIntoView({ block: "end" });
return;
}
chatRef.current.scrollTop = chatRef.current.scrollHeight;
const el = chatRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, []);
const setPinned = useCallback((nextPinned: boolean) => {
@@ -827,11 +823,11 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
}, [runStartedAt, showLiveAssistantCard, status]);
return (
<div className="relative flex-1 overflow-hidden">
<div className="relative h-full min-h-0 overflow-hidden">
<div
ref={chatRef}
data-testid="agent-chat-scroll"
className={`ui-chat-scroll ui-chat-scroll-borderless h-full overflow-auto p-4 dark:p-6 sm:p-5 dark:sm:p-7 ${showJumpToLatest ? "pb-20" : ""}`}
className={`ui-chat-scroll ui-chat-scroll-borderless h-full overflow-x-hidden overflow-y-auto p-3 sm:p-5 dark:sm:p-7 ${showJumpToLatest ? "pb-20" : ""}`}
onScroll={() => updatePinnedFromScroll()}
onWheel={(event) => {
event.stopPropagation();
@@ -840,23 +836,23 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
event.stopPropagation();
}}
>
<div className="relative flex flex-col gap-6 dark:gap-8 text-[14px] leading-[1.65] text-foreground">
<div className="relative flex min-w-0 flex-col gap-6 text-[14px] leading-[1.65] text-foreground dark:gap-8">
<div aria-hidden className={`pointer-events-none absolute ${SPINE_LEFT} top-0 bottom-0 w-px bg-border/20`} />
{showLoadMoreBanner ? (
<div className="-mx-1 flex items-center justify-between gap-3 rounded-md bg-surface-2 px-3 py-2 shadow-2xs">
<div className="type-meta min-w-0 truncate font-mono text-muted-foreground">
<div className="flex flex-col items-start gap-2 rounded-md bg-surface-2 px-3 py-2 shadow-2xs sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="type-meta w-full min-w-0 break-words font-mono text-muted-foreground sm:truncate">
{hasHiddenFetchedHistory && typeof historyFetchedCount === "number" && typeof visibleTurnCount === "number"
? `Showing latest ${visibleTurnCount} of ${historyFetchedCount} loaded turns`
: `Showing latest ${typeof visibleTurnCount === "number" ? visibleTurnCount : "?"} turns`}
</div>
{historyGatewayCapReached && !hasHiddenFetchedHistory ? (
<div className="type-meta shrink-0 font-mono text-muted-foreground">
<div className="type-meta w-full min-w-0 break-words font-mono text-muted-foreground sm:w-auto sm:shrink-0">
Showing latest retrievable history
</div>
) : (
<button
type="button"
className="shrink-0 rounded-md border border-border/70 bg-surface-3 px-3 py-1.5 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground transition hover:bg-surface-2"
className="inline-flex w-fit self-start rounded-md border border-border/70 bg-surface-3 px-3 py-1.5 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground transition hover:bg-surface-2 sm:self-auto"
onClick={onLoadMoreHistory}
>
{hasHiddenFetchedHistory ? "Show older" : "Load more"}
@@ -934,7 +930,6 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
onResolve={onResolveExecApproval}
/>
))}
<div ref={chatBottomRef} />
</>
)}
</div>
@@ -1034,7 +1029,7 @@ const AgentChatComposer = memo(function AgentChatComposer({
if (modelOptions.length === 0) return "No models found";
return modelOptions.find((option) => option.value === modelValue)?.label ?? modelValue;
}, [modelOptions, modelValue]);
const modelSelectWidthCh = Math.max(11, Math.min(44, modelSelectedLabel.length + 6));
const modelSelectWidthCh = Math.max(11, Math.min(30, modelSelectedLabel.length + 6));
const thinkingSelectedLabel = useMemo(() => {
switch (thinkingValue) {
case "off":
@@ -1053,9 +1048,9 @@ const AgentChatComposer = memo(function AgentChatComposer({
return "Default";
}
}, [thinkingValue]);
const thinkingSelectWidthCh = Math.max(9, Math.min(22, thinkingSelectedLabel.length + 6));
const thinkingSelectWidthCh = Math.max(9, Math.min(16, thinkingSelectedLabel.length + 6));
return (
<div className="rounded-2xl border border-border/65 bg-surface-2/45 px-3 py-2">
<div className="w-full max-w-full overflow-hidden rounded-2xl border border-border/65 bg-surface-2/45 px-3 py-2">
{queuedMessages.length > 0 ? (
<div
className={`mb-2 grid items-start gap-2 ${
@@ -1115,12 +1110,12 @@ const AgentChatComposer = memo(function AgentChatComposer({
</button>
</div>
) : null}
<div className="flex items-end gap-2">
<div className="flex min-w-0 items-end gap-2">
<textarea
ref={inputRef}
rows={1}
value={value}
className="chat-composer-input min-h-[28px] flex-1 resize-none border-0 bg-transparent px-0 py-1 text-[15px] leading-6 text-foreground outline-none shadow-none transition placeholder:text-muted-foreground/65 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
className="chat-composer-input min-h-[28px] max-h-[34vh] min-w-0 flex-1 resize-none border-0 bg-transparent px-0 py-1 text-[16px] leading-6 text-foreground outline-none shadow-none transition placeholder:text-muted-foreground/65 focus:outline-none focus-visible:outline-none focus-visible:ring-0 sm:text-[15px]"
onChange={onChange}
onKeyDown={onKeyDown}
placeholder="type a message"
@@ -1128,7 +1123,7 @@ const AgentChatComposer = memo(function AgentChatComposer({
{running ? (
<span className="inline-flex" title={stopReason || undefined}>
<button
className="rounded-md border border-border/70 bg-surface-3 px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
className="shrink-0 rounded-md border border-border/70 bg-surface-3 px-3.5 py-2.5 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground sm:px-3 sm:py-2"
type="button"
onClick={onStop}
disabled={stopDisabled}
@@ -1139,7 +1134,7 @@ const AgentChatComposer = memo(function AgentChatComposer({
</span>
) : null}
<button
className="ui-btn-primary ui-btn-send px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
className="ui-btn-primary ui-btn-send shrink-0 px-3.5 py-2.5 font-mono text-[12px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground sm:px-3 sm:py-2"
type="button"
onClick={onSend}
disabled={sendDisabled}
@@ -1147,14 +1142,14 @@ const AgentChatComposer = memo(function AgentChatComposer({
Send
</button>
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className="mt-2 flex flex-col gap-2 sm:mt-1 sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 sm:w-auto sm:flex-nowrap">
<InlineHoverTooltip text="Choose model">
<select
className="ui-input ui-control-important h-6 min-w-0 rounded-md px-1.5 text-[10px] font-semibold text-foreground"
className="ui-input ui-control-important h-6 min-w-0 max-w-full rounded-md px-1.5 text-[10px] font-semibold text-foreground"
aria-label="Model"
value={modelValue}
style={{ width: `${modelSelectWidthCh}ch` }}
style={{ width: `${modelSelectWidthCh}ch`, maxWidth: "clamp(12ch, 58vw, 30ch)" }}
onChange={(event) => {
const nextValue = event.target.value.trim();
onModelChange(nextValue ? nextValue : null);
@@ -1174,10 +1169,10 @@ const AgentChatComposer = memo(function AgentChatComposer({
{allowThinking ? (
<InlineHoverTooltip text="Select reasoning effort">
<select
className="ui-input ui-control-important h-6 rounded-md px-1.5 text-[10px] font-semibold text-foreground"
className="ui-input ui-control-important h-6 min-w-0 max-w-full rounded-md px-1.5 text-[10px] font-semibold text-foreground"
aria-label="Thinking"
value={thinkingValue}
style={{ width: `${thinkingSelectWidthCh}ch` }}
style={{ width: `${thinkingSelectWidthCh}ch`, maxWidth: "min(40vw, 16ch)" }}
onChange={(event) => {
const nextValue = event.target.value.trim();
onThinkingChange(nextValue ? nextValue : null);
@@ -1194,14 +1189,14 @@ const AgentChatComposer = memo(function AgentChatComposer({
</InlineHoverTooltip>
) : null}
</div>
<div className="ml-auto flex items-center gap-1.5 text-[10px] text-muted-foreground">
<div className="flex w-full flex-wrap items-center justify-end gap-1.5 text-[10px] text-muted-foreground sm:ml-auto sm:w-auto sm:flex-nowrap">
<span className="font-mono tracking-[0.02em]">Show</span>
<button
type="button"
role="switch"
aria-label="Show tool calls"
aria-checked={toolCallingEnabled}
className={`inline-flex h-5 items-center rounded-sm border px-1.5 font-mono text-[10px] tracking-[0.01em] transition ${
className={`inline-flex h-5 shrink-0 items-center rounded-sm border px-1.5 font-mono text-[10px] tracking-[0.01em] transition ${
toolCallingEnabled
? "border-primary/45 bg-primary/14 text-foreground"
: "border-border/70 bg-surface-2/40 text-muted-foreground hover:text-foreground"
@@ -1215,7 +1210,7 @@ const AgentChatComposer = memo(function AgentChatComposer({
role="switch"
aria-label="Show thinking"
aria-checked={showThinkingTraces}
className={`inline-flex h-5 items-center rounded-sm border px-1.5 font-mono text-[10px] tracking-[0.01em] transition ${
className={`inline-flex h-5 shrink-0 items-center rounded-sm border px-1.5 font-mono text-[10px] tracking-[0.01em] transition ${
showThinkingTraces
? "border-primary/45 bg-primary/14 text-foreground"
: "border-border/70 bg-surface-2/40 text-muted-foreground hover:text-foreground"
@@ -1259,6 +1254,7 @@ export const AgentChatPanel = ({
const [renameSaving, setRenameSaving] = useState(false);
const [renameDraft, setRenameDraft] = useState(agent.name);
const [renameError, setRenameError] = useState<string | null>(null);
const [transcriptModalOpen, setTranscriptModalOpen] = useState(false);
const draftRef = useRef<HTMLTextAreaElement | null>(null);
const renameInputRef = useRef<HTMLInputElement | null>(null);
const renameEditorRef = useRef<HTMLDivElement | null>(null);
@@ -1333,6 +1329,10 @@ export const AgentChatPanel = ({
setRenameDraft(agent.name);
}, [agent.agentId, agent.name]);
useEffect(() => {
setTranscriptModalOpen(false);
}, [agent.agentId, agent.sessionKey]);
useEffect(() => {
if (!renameEditing) return;
const frameId = requestAnimationFrame(() => {
@@ -1596,9 +1596,9 @@ export const AgentChatPanel = ({
const newSessionDisabled = newSessionBusy || !canSend || !onNewSession;
return (
<div data-agent-panel className="group fade-up relative flex h-full w-full flex-col">
<div data-agent-panel className="group fade-up relative flex h-full w-full min-w-0 flex-col overflow-hidden">
<div className="px-3 pt-2 sm:px-4 sm:pt-3">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="group/avatar relative">
<AgentAvatar
@@ -1625,7 +1625,7 @@ export const AgentChatPanel = ({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 w-[clamp(11rem,34vw,16rem)]">
<div className="min-w-0 w-[clamp(8.5rem,42vw,16rem)] sm:w-[clamp(11rem,34vw,16rem)]">
{renameEditing ? (
<div ref={renameEditorRef} className="flex h-8 items-center gap-1.5">
<input
@@ -1690,7 +1690,16 @@ export const AgentChatPanel = ({
</div>
</div>
<div className="mt-0.5 flex items-center gap-2">
<div className="mt-0.5 flex w-full items-center justify-end gap-2 sm:w-auto">
<button
className="nodrag ui-btn-icon !inline-flex md:!hidden"
type="button"
aria-label="Expand transcript"
title="Expand transcript"
onClick={() => setTranscriptModalOpen(true)}
>
<Maximize2 className="h-4 w-4" />
</button>
<button
className="nodrag ui-btn-primary px-2.5 py-1.5 font-mono text-[11px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
type="button"
@@ -1718,37 +1727,39 @@ export const AgentChatPanel = ({
</div>
</div>
<div className="mt-3 flex min-h-0 flex-1 flex-col px-3 pb-3 sm:px-4 sm:pb-4">
<AgentChatTranscript
agentId={agent.agentId}
name={agent.name}
avatarSeed={avatarSeed}
avatarUrl={agent.avatarUrl ?? null}
status={agent.status}
historyMaybeTruncated={agent.historyMaybeTruncated}
historyGatewayCapReached={agent.historyGatewayCapReached ?? false}
historyFetchedCount={agent.historyFetchedCount}
historyVisibleTurnLimit={agent.historyVisibleTurnLimit ?? null}
onLoadMoreHistory={onLoadMoreHistory}
renderBlocks={renderBlocks}
liveThinkingText={liveThinkingText}
liveAssistantText={liveAssistantText}
showTypingIndicator={showTypingIndicator}
outputLineCount={visibleOutputLines.length}
liveAssistantCharCount={liveAssistantText.length}
liveThinkingCharCount={liveThinkingText.length}
runStartedAt={agent.runStartedAt}
scrollToBottomOnOpenKey={scrollToBottomOnOpenKey}
scrollToBottomNextOutputRef={scrollToBottomNextOutputRef}
pendingExecApprovals={pendingExecApprovals}
onResolveExecApproval={onResolveExecApproval}
emptyStateTitle={emptyStateTitle}
lastUserMessage={agent.lastUserMessage}
latestPreview={agent.latestPreview}
previewItems={agent.previewItems}
/>
<div className="mt-3 flex min-h-0 flex-1 flex-col px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4 sm:pb-4">
<div className="relative h-0 min-h-0 flex-1 overflow-hidden">
<AgentChatTranscript
agentId={agent.agentId}
name={agent.name}
avatarSeed={avatarSeed}
avatarUrl={agent.avatarUrl ?? null}
status={agent.status}
historyMaybeTruncated={agent.historyMaybeTruncated}
historyGatewayCapReached={agent.historyGatewayCapReached ?? false}
historyFetchedCount={agent.historyFetchedCount}
historyVisibleTurnLimit={agent.historyVisibleTurnLimit ?? null}
onLoadMoreHistory={onLoadMoreHistory}
renderBlocks={renderBlocks}
liveThinkingText={liveThinkingText}
liveAssistantText={liveAssistantText}
showTypingIndicator={showTypingIndicator}
outputLineCount={visibleOutputLines.length}
liveAssistantCharCount={liveAssistantText.length}
liveThinkingCharCount={liveThinkingText.length}
runStartedAt={agent.runStartedAt}
scrollToBottomOnOpenKey={scrollToBottomOnOpenKey}
scrollToBottomNextOutputRef={scrollToBottomNextOutputRef}
pendingExecApprovals={pendingExecApprovals}
onResolveExecApproval={onResolveExecApproval}
emptyStateTitle={emptyStateTitle}
lastUserMessage={agent.lastUserMessage}
latestPreview={agent.latestPreview}
previewItems={agent.previewItems}
/>
</div>
<div className="mt-3">
<div className="relative z-20 mt-3">
<AgentChatComposer
value={draftValue}
inputRef={handleDraftRef}
@@ -1779,6 +1790,50 @@ export const AgentChatPanel = ({
/>
</div>
</div>
{transcriptModalOpen ? (
<div className="fixed inset-0 z-[130] flex min-h-0 flex-col bg-background md:hidden">
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
<div className="truncate text-sm font-medium text-foreground">{agent.name} · Transcript</div>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-xs"
onClick={() => setTranscriptModalOpen(false)}
>
Close
</button>
</div>
<div className="h-0 min-h-0 flex-1 overflow-hidden px-2 pb-2">
<AgentChatTranscript
agentId={agent.agentId}
name={agent.name}
avatarSeed={avatarSeed}
avatarUrl={agent.avatarUrl ?? null}
status={agent.status}
historyMaybeTruncated={false}
historyGatewayCapReached={false}
historyFetchedCount={null}
historyVisibleTurnLimit={null}
onLoadMoreHistory={() => {}}
renderBlocks={renderBlocks}
liveThinkingText={liveThinkingText}
liveAssistantText={liveAssistantText}
showTypingIndicator={showTypingIndicator}
outputLineCount={visibleOutputLines.length}
liveAssistantCharCount={liveAssistantText.length}
liveThinkingCharCount={liveThinkingText.length}
runStartedAt={agent.runStartedAt}
scrollToBottomOnOpenKey={`${scrollToBottomOnOpenKey}:modal`}
scrollToBottomNextOutputRef={scrollToBottomNextOutputRef}
pendingExecApprovals={[]}
emptyStateTitle={emptyStateTitle}
lastUserMessage={agent.lastUserMessage}
latestPreview={agent.latestPreview}
previewItems={agent.previewItems}
/>
</div>
</div>
) : null}
</div>
);
};
@@ -209,7 +209,10 @@ export function planFocusedPreferenceRestore(params: {
};
}
const restoredFilter = preference.filter === "running" ? "all" : preference.filter;
const restoredFilter =
preference.filter === "running" || preference.filter === "approvals"
? "all"
: preference.filter;
return {
preferredSelectedAgentId: preference.selectedAgentId,
focusFilter: restoredFilter,
@@ -178,6 +178,36 @@ describe("AgentChatPanel controls", () => {
expect(onNewSession).toHaveBeenCalledTimes(1);
});
it("opens read-only transcript modal from expand icon", async () => {
render(
createElement(AgentChatPanel, {
agent: {
...createAgent(),
outputLines: ["> show transcript", "Hello from assistant"],
},
isSelected: true,
canSend: true,
models,
stopBusy: false,
onLoadMoreHistory: vi.fn(),
onOpenSettings: vi.fn(),
onModelChange: vi.fn(),
onThinkingChange: vi.fn(),
onDraftChange: vi.fn(),
onSend: vi.fn(),
onStopRun: vi.fn(),
onAvatarShuffle: vi.fn(),
})
);
fireEvent.click(screen.getByLabelText("Expand transcript"));
expect(screen.getByText(/Agent One · Transcript/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Close/i }));
await waitFor(() => {
expect(screen.queryByText(/Agent One · Transcript/i)).not.toBeInTheDocument();
});
});
it("does_not_render_inline_status_badge_markers", () => {
const { rerender, container } = render(
createElement(AgentChatPanel, {
+70 -12
View File
@@ -50,7 +50,8 @@ describe("AgentChatPanel scrolling", () => {
});
it("shows jump-to-latest when unpinned and new output arrives, and jumps on click", async () => {
(Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = vi.fn();
const scrollIntoView = vi.fn();
(Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = scrollIntoView;
const agent = createAgent();
const { rerender } = render(
@@ -102,10 +103,8 @@ describe("AgentChatPanel scrolling", () => {
fireEvent.click(screen.getByRole("button", { name: "Jump to latest" }));
expect(
(Element.prototype as unknown as { scrollIntoView: ReturnType<typeof vi.fn> })
.scrollIntoView
).toHaveBeenCalled();
expect(scrollEl.scrollTop).toBe(1000);
expect(scrollIntoView).not.toHaveBeenCalled();
});
it("scrolls to the bottom when a different agent is opened", async () => {
@@ -137,12 +136,6 @@ describe("AgentChatPanel scrolling", () => {
Object.defineProperty(scrollEl, "scrollHeight", { value: 1000, configurable: true });
Object.defineProperty(scrollEl, "scrollTop", { value: 0, writable: true, configurable: true });
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalled();
});
scrollIntoView.mockClear();
rerender(
createElement(AgentChatPanel, {
agent: createAgent({
@@ -167,8 +160,9 @@ describe("AgentChatPanel scrolling", () => {
);
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalled();
expect(scrollEl.scrollTop).toBe(1000);
});
expect(scrollIntoView).not.toHaveBeenCalled();
});
it("shows history truncation banner only when scrolled to top", () => {
@@ -209,4 +203,68 @@ describe("AgentChatPanel scrolling", () => {
fireEvent.scroll(scrollEl);
expect(screen.getByText(/Showing latest 200 turns/i)).toBeInTheDocument();
});
it("keeps the transcript pinned to the chat container when sending", async () => {
const scrollIntoView = vi.fn();
(Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = scrollIntoView;
const onSend = vi.fn();
const agent = createAgent({
draft: "Investigate the scroll jump",
outputLines: ["> earlier question", "earlier answer"],
});
const { rerender } = render(
createElement(AgentChatPanel, {
agent,
isSelected: true,
canSend: true,
models,
stopBusy: false,
onLoadMoreHistory: vi.fn(),
onOpenSettings: vi.fn(),
onModelChange: vi.fn(),
onThinkingChange: vi.fn(),
onDraftChange: vi.fn(),
onSend,
onStopRun: vi.fn(),
onAvatarShuffle: vi.fn(),
})
);
const scrollEl = screen.getByTestId("agent-chat-scroll");
Object.defineProperty(scrollEl, "clientHeight", { value: 100, configurable: true });
Object.defineProperty(scrollEl, "scrollHeight", { value: 1000, writable: true, configurable: true });
Object.defineProperty(scrollEl, "scrollTop", { value: 900, writable: true, configurable: true });
fireEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onSend).toHaveBeenCalledWith("Investigate the scroll jump");
Object.defineProperty(scrollEl, "scrollHeight", { value: 1040, writable: true, configurable: true });
rerender(
createElement(AgentChatPanel, {
agent: {
...agent,
draft: "",
outputLines: ["> earlier question", "earlier answer", "> Investigate the scroll jump"],
},
isSelected: true,
canSend: true,
models,
stopBusy: false,
onLoadMoreHistory: vi.fn(),
onOpenSettings: vi.fn(),
onModelChange: vi.fn(),
onThinkingChange: vi.fn(),
onDraftChange: vi.fn(),
onSend,
onStopRun: vi.fn(),
onAvatarShuffle: vi.fn(),
})
);
await waitFor(() => {
expect(scrollEl.scrollTop).toBe(1040);
});
expect(scrollIntoView).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("markdown <pre> wrap regression guard", () => {
it("keeps pre/code blocks constrained and wrappable in chat markdown", () => {
const cssPath = path.resolve(process.cwd(), "src/app/styles/markdown.css");
const css = readFileSync(cssPath, "utf8");
expect(css).toContain(".agent-markdown pre {");
expect(css).toContain("max-width: 100%;");
expect(css).toContain("min-width: 0;");
expect(css).toContain(".agent-markdown pre,");
expect(css).toContain(".agent-markdown pre code {");
expect(css).toContain("white-space: pre-wrap;");
expect(css).toContain("overflow-wrap: anywhere;");
expect(css).toContain("word-break: break-word;");
});
});
+1 -1
View File
@@ -180,7 +180,7 @@ describe("studioBootstrapWorkflow", () => {
})
).toEqual({
preferredSelectedAgentId: "agent-3",
focusFilter: "approvals",
focusFilter: "all",
});
expect(