From 2aa6768921f9b0cc1509169459f0dbe92f8b659e Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:47:57 +0100 Subject: [PATCH] show chat progress and other UX improvements (#6576) --- .taskfiles/engine.yml | 17 +- .../public/locales/en-GB/translation.toml | 7 + .../src/core/components/chat/ChatContext.tsx | 1 + .../components/chat/ChatContext.tsx | 78 ++++- .../proprietary/components/chat/ChatPanel.css | 184 ++++++++++- .../proprietary/components/chat/ChatPanel.tsx | 300 +++++++++++++++--- 6 files changed, 521 insertions(+), 66 deletions(-) diff --git a/.taskfiles/engine.yml b/.taskfiles/engine.yml index 5c9d6a8e6..1834495b1 100644 --- a/.taskfiles/engine.yml +++ b/.taskfiles/engine.yml @@ -1,5 +1,12 @@ version: '3' +vars: + # Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_* + # vars (Task merges included-file vars into the global scope). + # Paths are relative to the engine/ include dir. + ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh" + ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1" + tasks: install: desc: "Install engine dependencies" @@ -29,7 +36,10 @@ tasks: ignore_error: true dir: src vars: - PORT: '{{.PORT | default "5001"}}' + # When PORT is provided (e.g. from dev:all), use it directly. + # When running standalone, probe for a free port starting at 5001. + PORT: + sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}' env: PYTHONUNBUFFERED: "1" cmds: @@ -41,7 +51,10 @@ tasks: ignore_error: true dir: src vars: - PORT: '{{.PORT | default "5001"}}' + # When PORT is provided (e.g. from dev:all), use it directly. + # When running standalone, probe for a free port starting at 5001. + PORT: + sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}' env: PYTHONUNBUFFERED: "1" cmds: diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 71c77f866..e907cba19 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2700,6 +2700,13 @@ whole_doc_compression_round = "Consolidating notes..." whole_doc_read_done = "Finished reading the document..." whole_doc_read_started = "Reading the document..." whole_doc_slice_done = "Reading the document... ({{percent}}% complete)" +ranForSeconds = "Ran for {{count}} seconds" +ranForSeconds_one = "Ran for 1 second" +ranForSeconds_other = "Ran for {{count}} seconds" +ranForMinutes = "Ran for {{count}} minutes" +ranForMinutes_one = "Ran for 1 minute" +ranForMinutes_other = "Ran for {{count}} minutes" +ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec" [chat.quickActions] browseYourFiles = "Browse your files" diff --git a/frontend/editor/src/core/components/chat/ChatContext.tsx b/frontend/editor/src/core/components/chat/ChatContext.tsx index 3ef7276fb..621fafd6e 100644 --- a/frontend/editor/src/core/components/chat/ChatContext.tsx +++ b/frontend/editor/src/core/components/chat/ChatContext.tsx @@ -10,6 +10,7 @@ export function useChat() { isOpen: false, isLoading: false, progress: null, + progressLog: [] as never[], toggleOpen: () => {}, setOpen: (_open: boolean) => {}, sendMessage: async (_content: string) => {}, diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index 6cd473eda..eb0534c1b 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -36,6 +36,14 @@ export interface ChatMessage { * turns that answered without running any tool. */ toolsUsed?: string[]; + /** + * Full ordered progress log captured during the AI turn that produced this message. + * Only set on assistant messages; used to render the "Ran for X seconds" collapsed + * history dropdown above the response. + */ + progressLog?: AiWorkflowProgress[]; + /** Wall-clock duration of the AI turn in milliseconds. Only set on assistant messages. */ + durationMs?: number; } export enum AiWorkflowPhase { @@ -177,12 +185,20 @@ interface ChatState { isOpen: boolean; isLoading: boolean; progress: AiWorkflowProgress | null; + /** Ordered log of every progress event in the current request. UI shows the last N entries. */ + progressLog: AiWorkflowProgress[]; } +/** + * Maximum number of progress steps retained in the live buffer. + */ +export const PROGRESS_LOG_MAX = 4; + type ChatAction = | { type: "ADD_MESSAGE"; message: ChatMessage } | { type: "SET_LOADING"; loading: boolean } | { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null } + | { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress } | { type: "TOGGLE_OPEN" } | { type: "SET_OPEN"; open: boolean } | { type: "CLEAR" }; @@ -192,15 +208,40 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState { case "ADD_MESSAGE": return { ...state, messages: [...state.messages, action.message] }; case "SET_LOADING": - return { ...state, isLoading: action.loading }; + // Reset the log on both start (true) and end (false) of a request. + return { + ...state, + isLoading: action.loading, + progress: action.loading ? state.progress : null, + progressLog: [], + }; case "SET_PROGRESS": return { ...state, progress: action.progress }; + case "APPEND_PROGRESS": + // Cap the live buffer so each append copies at most PROGRESS_LOG_MAX elements + return { + ...state, + progress: action.progress, + progressLog: + state.progressLog.length < PROGRESS_LOG_MAX + ? [...state.progressLog, action.progress] + : [ + ...state.progressLog.slice(1 - PROGRESS_LOG_MAX), + action.progress, + ], + }; case "TOGGLE_OPEN": return { ...state, isOpen: !state.isOpen }; case "SET_OPEN": return { ...state, isOpen: action.open }; case "CLEAR": - return { ...state, messages: [], isLoading: false, progress: null }; + return { + ...state, + messages: [], + isLoading: false, + progress: null, + progressLog: [], + }; } } @@ -324,6 +365,8 @@ interface ChatContextValue { isOpen: boolean; isLoading: boolean; progress: AiWorkflowProgress | null; + /** Ordered log of every progress event for the current in-flight request. */ + progressLog: AiWorkflowProgress[]; toggleOpen: () => void; setOpen: (open: boolean) => void; sendMessage: (content: string) => Promise; @@ -338,6 +381,7 @@ const initialState: ChatState = { isOpen: false, isLoading: false, progress: null, + progressLog: [], }; export function ChatProvider({ children }: { children: ReactNode }) { @@ -439,6 +483,11 @@ export function ChatProvider({ children }: { children: ReactNode }) { abortRef.current = controller; const priorMessages = messagesRef.current; + const startTime = Date.now(); + // Mirror every progress event locally so we can attach the full log to + // the assistant message when the result arrives — without needing a ref + // into the reducer state. + const progressLogLocal: AiWorkflowProgress[] = []; const userMessage: ChatMessage = { id: generateId(), @@ -499,16 +548,15 @@ export function ChatProvider({ children }: { children: ReactNode }) { ) { toolsUsed.push(data.tool); } - dispatch({ - type: "SET_PROGRESS", - progress: { - phase: data.phase as AiWorkflowPhase, - tool: data.tool, - stepIndex: data.stepIndex, - stepCount: data.stepCount, - engineDetail: data.engineDetail, - }, - }); + const progressItem: AiWorkflowProgress = { + phase: data.phase as AiWorkflowPhase, + tool: data.tool, + stepIndex: data.stepIndex, + stepCount: data.stepCount, + engineDetail: data.engineDetail, + }; + progressLogLocal.push(progressItem); + dispatch({ type: "APPEND_PROGRESS", progress: progressItem }); }, onResult: (data) => { receivedResult = true; @@ -522,6 +570,11 @@ export function ChatProvider({ children }: { children: ReactNode }) { content: replyContent, timestamp: Date.now(), toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined, + progressLog: + progressLogLocal.length > 0 + ? [...progressLogLocal] + : undefined, + durationMs: Date.now() - startTime, }, }); if (data.fileId || data.resultFiles?.length) { @@ -595,6 +648,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { isOpen: state.isOpen, isLoading: state.isLoading, progress: state.progress, + progressLog: state.progressLog, toggleOpen, setOpen, sendMessage, diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css index 0176172ce..62402108f 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.css +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css @@ -396,7 +396,182 @@ margin-bottom: 0; } -/* Thinking indicator */ +/* ─── Progress step log ─────────────────────────────────────────────────── */ + +/* + * Shown while the AI is working. Each step slides in from below as events + * arrive; the active (last) step is full-opacity with a pulsing icon, and + * earlier completed steps are dimmed. Only the most-recent N steps are + * rendered — there is no scrollable list. + */ + +.chat-progress-log { + display: flex; + flex-direction: column; + padding: 0.15rem 0.5rem 0.35rem; + /* No gap — the connector line div provides the inter-step spacing. */ +} + +.chat-progress-step { + display: flex; + align-items: flex-start; + gap: 0.65rem; + /* + * Use color rather than opacity for past-step dimming so the connector + * line (a sibling element inside __left, not a text node) is NOT affected + * and stays clearly visible. currentColor flows into SVG icon fills; + * color inherits into the label span automatically. + */ + color: var(--text-muted); + transition: color 250ms ease-out; + animation: chat-step-enter 300ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +/* + * Active step: white text on dark mode so currentColor feeds directly into + * the StirlingLogoAnimated SVG paths and the label at the same time. + * Light-mode falls back to the default text colour so it's not invisible. + */ +/* Active step: icon and label are coloured independently so the logo can be + blue while the text uses the normal text colour. */ +.chat-progress-step--active .chat-progress-step__icon { + color: var(--mantine-color-blue-filled); +} + +.chat-progress-step--active .chat-progress-step__label { + color: var(--mantine-color-text); +} + +[data-mantine-color-scheme="dark"] + .chat-progress-step--active + .chat-progress-step__label { + color: #ffffff; +} + +.chat-progress-step__left { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + width: 20px; +} + +/* Icon cell — fixed height so the connector line aligns cleanly. */ +.chat-progress-step__icon { + width: 20px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + /* Inherits color from .chat-progress-step so currentColor flows into icons. */ +} + +/* + * Connector line between steps. Not a child of the label so it is unaffected + * by the step's color; it sits on its own and reads clearly against the + * toolbar background. + */ +.chat-progress-step__line { + width: 1px; + height: 18px; + background: var(--border-subtle); + flex-shrink: 0; + margin-top: 2px; +} + +/* Active step label — slightly larger, inherits the active color. */ +.chat-progress-step__label { + font-size: 0.875rem; + padding-top: 3px; + line-height: 1.35; +} + +/* Past step labels — a touch smaller so the active row reads as the primary. */ +.chat-progress-step:not(.chat-progress-step--active) + .chat-progress-step__label { + font-size: 0.8rem; + padding-top: 4px; +} + +/* + * Wrapper that scales a registry tool icon (LocalIcon, typically 1.5rem/24px) + * down to fit the 20px icon column. Transform does not affect layout, so the + * parent overflow:hidden clips the un-scaled footprint cleanly. + */ +.chat-step-icon-scaled { + display: inline-flex; + align-items: center; + justify-content: center; + transform: scale(0.75); + transform-origin: center; + flex-shrink: 0; +} + +/* Entry: steps slide up from a few pixels below their final position. */ +@keyframes chat-step-enter { + from { + transform: translateY(7px); + } + to { + transform: translateY(0); + } +} + +/* ─── Completed progress log dropdown ──────────────────────────────────── */ + +/* + * Shown above each completed assistant turn. The toggle displays a small + * "Ran for X seconds" label; expanding reveals the full ordered step list + * for that turn in a compact, read-only format. + */ + +.chat-completed-log { + margin-bottom: 0.55rem; +} + +.chat-completed-log__toggle { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.25rem 0.1rem 0.1rem; + border-radius: 0.35rem; + color: var(--text-muted); + transition: background 120ms ease-out; +} + +.chat-completed-log__toggle:hover { + background: var(--mantine-color-default-hover); +} + +.chat-completed-log__steps { + display: flex; + flex-direction: column; + padding: 0.35rem 0.4rem 0.2rem 0.25rem; + margin-top: 0.2rem; +} + +/* In the completed log every step is a past step — no active highlighting, + no entry animation (the content is static once opened). */ +.chat-completed-log__steps .chat-progress-step { + animation: none; +} + +/* Tighten the icon and connector dimensions for the denser historical view. */ +.chat-completed-log__steps .chat-progress-step__icon { + height: 20px; +} + +.chat-completed-log__steps .chat-progress-step__line { + height: 14px; +} + +.chat-completed-log__steps .chat-progress-step__label { + font-size: 0.775rem; + padding-top: 2px; +} + +/* Legacy thinking indicator (kept in case any other code references it). */ .chat-thinking { display: flex; align-items: center; @@ -405,6 +580,13 @@ color: var(--mantine-color-blue-filled); } +@media (prefers-reduced-motion: reduce) { + .chat-progress-step { + animation: none; + transition: none; + } +} + /* ─── Stirling logo thinking animation ─────────────────────────────────── */ .stirling-thinking__path-right { diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx index 7baf5d0a5..ff0fdc63b 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, type KeyboardEvent, + type ReactNode, } from "react"; import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer"; import { TFunction } from "i18next"; @@ -13,7 +14,6 @@ import { Box, Collapse, Group, - List, Menu, Paper, ScrollArea, @@ -23,7 +23,10 @@ import { UnstyledButton, } from "@mantine/core"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined"; +import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined"; import CloseIcon from "@mui/icons-material/Close"; +import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DeleteSweepIcon from "@mui/icons-material/DeleteSweep"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; @@ -33,14 +36,15 @@ import { useChat, AiWorkflowPhase, ChatRole, + PROGRESS_LOG_MAX, isKnownEngineProgressDetail, type AiWorkflowProgress, type AnyEngineProgressDetail, } from "@app/components/chat/ChatContext"; import { formatRelativeTime } from "@app/utils/timeUtils"; import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; -import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated"; +import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; import { ChatQuickActions } from "@app/components/chat/ChatQuickActions"; import "@app/components/chat/ChatPanel.css"; @@ -68,6 +72,27 @@ function useToolNameResolver(): ToolNameResolver { }, [allTools]); } +/** Resolver mapping a tool endpoint path to its registry icon ReactNode. */ +type ToolIconResolver = (endpoint: string) => ReactNode | null; + +/** + * Look up a tool's icon ReactNode from the tool catalog, keyed by API endpoint path. + * Returns null when the endpoint is not found (use a generic fallback icon in that case). + */ +function useToolIconResolver(): ToolIconResolver { + const { allTools } = useTranslatedToolCatalog(); + return useMemo(() => { + const iconByEndpoint = new Map(); + Object.values(allTools).forEach((tool) => { + const endpoint = tool.operationConfig?.endpoint; + if (typeof endpoint === "string") { + iconByEndpoint.set(endpoint, tool.icon); + } + }); + return (endpoint: string) => iconByEndpoint.get(endpoint) ?? null; + }, [allTools]); +} + function formatProgress( progress: AiWorkflowProgress, t: TranslateFn, @@ -125,31 +150,152 @@ function formatEngineProgress( } } -function ToolsUsedBlock({ - tools, - resolveToolName, +/** + * Choose an icon for a progress step. + * + * The active (current) step always shows the animated Stirling logo so it reads + * as the "live" indicator. Past steps get a phase-specific icon so the trail + * is scannable at a glance. + */ +function progressStepIcon( + progress: AiWorkflowProgress, + resolveToolIcon: ToolIconResolver, + isActive: boolean, +): ReactNode { + if (isActive) { + return ; + } + if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) { + const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null; + if (registryIcon) { + return {registryIcon}; + } + return ; + } + if ( + progress.phase === AiWorkflowPhase.EXTRACTING_CONTENT || + progress.phase === AiWorkflowPhase.ENGINE_PROGRESS + ) { + return ; + } + return ; +} + +/** + * Animated step-by-step progress log shown while the AI is working. + * Displays the last {@link PROGRESS_LOG_VISIBLE} steps from the live event stream, + * with the active (most recent) step highlighted and older steps dimmed. + */ +function ProgressLogDisplay({ + progressLog, t, + resolveToolName, + resolveToolIcon, }: { - tools: string[]; - resolveToolName: ToolNameResolver; + progressLog: AiWorkflowProgress[]; t: TranslateFn; + resolveToolName: ToolNameResolver; + resolveToolIcon: ToolIconResolver; +}) { + // Placeholder shown before the first SSE event arrives. + if (progressLog.length === 0) { + return ( +
+
+
+
+ +
+
+ + {t("chat.progress.thinking")} + +
+
+ ); + } + + // Chronological order: oldest at top, newest (active) at bottom. + // The reducer already caps progressLog at PROGRESS_LOG_MAX entries, so this + // slice is effectively a no-op but kept for defensive correctness. + const visibleSteps = progressLog.slice(-PROGRESS_LOG_MAX); + const startIndex = progressLog.length - visibleSteps.length; + + return ( +
+ {visibleSteps.map((step, i) => { + // Stable key based on absolute position in the full log — React reuses + // existing DOM elements and only mounts (and animates) new ones. + const globalIndex = startIndex + i; + const isActive = i === visibleSteps.length - 1; // last = newest = bottom + // Connector runs below every step except the active one at the bottom. + const showConnector = i < visibleSteps.length - 1; + const label = formatProgress(step, t, resolveToolName); + return ( +
+
+
+ {progressStepIcon(step, resolveToolIcon, isActive)} +
+ {showConnector &&
} +
+ {label} +
+ ); + })} +
+ ); +} + +function formatDuration(ms: number, t: TranslateFn): string { + const totalSeconds = Math.max(1, Math.round(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes === 0) { + return t("chat.progress.ranForSeconds", { count: totalSeconds }); + } + if (seconds === 0) { + return t("chat.progress.ranForMinutes", { count: minutes }); + } + return t("chat.progress.ranForMinutesSeconds", { minutes, seconds }); +} + +/** + * Collapsed "Ran for X seconds" dropdown that appears above each completed + * assistant turn. Expands to show the full ordered progress log for that turn. + */ +function CompletedProgressLogDropdown({ + progressLog, + durationMs, + t, + resolveToolName, + resolveToolIcon, +}: { + progressLog: AiWorkflowProgress[]; + durationMs: number; + t: TranslateFn; + resolveToolName: ToolNameResolver; + resolveToolIcon: ToolIconResolver; }) { const [expanded, setExpanded] = useState(false); - const names = tools.map( - (endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"), - ); - const label = t("chat.toolsUsed.summary", { count: tools.length }); + const label = formatDuration(durationMs, t); + return ( - +
setExpanded((v) => !v)} aria-expanded={expanded} > - + {expanded ? ( - + ) : ( - + )} {label} @@ -157,19 +303,30 @@ function ToolsUsedBlock({ - - {names.map((name, i) => ( - {name} - ))} - +
+ {progressLog.map((step, i) => { + const showConnector = i < progressLog.length - 1; + const stepLabel = formatProgress(step, t, resolveToolName); + return ( +
+
+
+ {progressStepIcon(step, resolveToolIcon, false)} +
+ {showConnector && ( +
+ )} +
+ {stepLabel} +
+ ); + })} +
- +
); } @@ -177,15 +334,19 @@ function ChatMessageBubble({ role, content, timestamp, - toolsUsed, + progressLog, + durationMs, resolveToolName, + resolveToolIcon, t, }: { role: ChatRole; content: string; timestamp: number; - toolsUsed?: string[]; + progressLog?: AiWorkflowProgress[]; + durationMs?: number; resolveToolName: ToolNameResolver; + resolveToolIcon: ToolIconResolver; t: TranslateFn; }) { const [copied, setCopied] = useState(false); @@ -231,16 +392,18 @@ function ChatMessageBubble({ return (
+ {progressLog && progressLog.length > 0 && durationMs != null && ( + + )} {renderMarkdown(content)} - {toolsUsed && toolsUsed.length > 0 && ( - - )} {actions}
@@ -256,20 +419,55 @@ export interface ChatPanelProps { export function ChatPanel({ onBack, backLabel }: ChatPanelProps) { const { t } = useTranslation(); - const { messages, isLoading, progress, sendMessage, clearChat } = useChat(); + const { messages, isLoading, progressLog, sendMessage, clearChat } = + useChat(); const resolveToolName = useToolNameResolver(); + const resolveToolIcon = useToolIconResolver(); const [input, setInput] = useState(""); const scrollRef = useRef(null); const inputRef = useRef(null); + // Tracks whether the user manually scrolled away from the bottom. + // A ref (not state) so scroll events don't cause re-renders. + const userScrolledUp = useRef(false); + // Jump to the bottom on first render so existing conversations open at the + // most recent message rather than the top. useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTo({ - top: scrollRef.current.scrollHeight, - behavior: "smooth", + requestAnimationFrame(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }); + }, []); + + // Attach a passive scroll listener to track whether the user has scrolled + // away from the bottom (breaks auto-scroll) or returned to it (re-latches). + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onScroll = () => { + const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + userScrolledUp.current = distFromBottom > 50; + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, []); + + // Scroll to the bottom when messages arrive or live progress steps update, + // unless the user has scrolled up (they're reading history). + // Scrolling back to the bottom resets the ref, so the next update re-latches. + // + // RAF defers the scroll until after the browser has laid out the new nodes, + // so scrollHeight is correct. Direct scrollTop assignment avoids the + // smooth-scroll interruption problem that occurs when SSE events arrive + // faster than a smooth animation can complete. + useEffect(() => { + if (!userScrolledUp.current) { + requestAnimationFrame(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; }); } - }, [messages]); + }, [messages, progressLog]); useEffect(() => { inputRef.current?.focus(); @@ -343,21 +541,21 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) { role={msg.role} content={msg.content} timestamp={msg.timestamp} - toolsUsed={msg.toolsUsed} + progressLog={msg.progressLog} + durationMs={msg.durationMs} resolveToolName={resolveToolName} + resolveToolIcon={resolveToolIcon} t={t} /> ))} {isLoading && (
-
- - - {progress - ? formatProgress(progress, t, resolveToolName) - : t("chat.progress.thinking")} - -
+
)}