mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Fix chat scroll jumping
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/.tmp-vitest/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -23,6 +24,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.worktrees/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
});
|
||||
@@ -180,7 +180,7 @@ describe("studioBootstrapWorkflow", () => {
|
||||
})
|
||||
).toEqual({
|
||||
preferredSelectedAgentId: "agent-3",
|
||||
focusFilter: "approvals",
|
||||
focusFilter: "all",
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user