show chat progress and other UX improvements (#6576)

This commit is contained in:
EthanHealy01
2026-06-10 14:47:57 +01:00
committed by GitHub
parent f15e405759
commit 2aa6768921
6 changed files with 521 additions and 66 deletions
+15 -2
View File
@@ -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:
@@ -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"
@@ -10,6 +10,7 @@ export function useChat() {
isOpen: false,
isLoading: false,
progress: null,
progressLog: [] as never[],
toggleOpen: () => {},
setOpen: (_open: boolean) => {},
sendMessage: async (_content: string) => {},
@@ -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<void>;
@@ -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: {
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,
@@ -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 {
@@ -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<string, ReactNode>();
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 <StirlingLogoAnimated size={18} />;
}
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) {
const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null;
if (registryIcon) {
return <span className="chat-step-icon-scaled">{registryIcon}</span>;
}
return <BuildOutlinedIcon sx={{ fontSize: 17 }} />;
}
if (
progress.phase === AiWorkflowPhase.EXTRACTING_CONTENT ||
progress.phase === AiWorkflowPhase.ENGINE_PROGRESS
) {
return <ArticleOutlinedIcon sx={{ fontSize: 17 }} />;
}
return <CloudOutlinedIcon sx={{ fontSize: 17 }} />;
}
/**
* 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 (
<div className="chat-progress-log">
<div className="chat-progress-step chat-progress-step--active">
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
<StirlingLogoAnimated size={18} />
</div>
</div>
<span className="chat-progress-step__label">
{t("chat.progress.thinking")}
</span>
</div>
</div>
);
}
// 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 (
<div className="chat-progress-log">
{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 (
<div
key={globalIndex}
className={`chat-progress-step${isActive ? " chat-progress-step--active" : ""}`}
>
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
{progressStepIcon(step, resolveToolIcon, isActive)}
</div>
{showConnector && <div className="chat-progress-step__line" />}
</div>
<span className="chat-progress-step__label">{label}</span>
</div>
);
})}
</div>
);
}
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 (
<Box mt={6}>
<div className="chat-completed-log">
<UnstyledButton
className="chat-completed-log__toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<Group gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" align="center">
{expanded ? (
<ExpandLessIcon sx={{ fontSize: 14 }} />
<ExpandLessIcon sx={{ fontSize: 12 }} />
) : (
<ExpandMoreIcon sx={{ fontSize: 14 }} />
<ExpandMoreIcon sx={{ fontSize: 12 }} />
)}
<Text size="xs" c="dimmed">
{label}
@@ -157,19 +303,30 @@ function ToolsUsedBlock({
</Group>
</UnstyledButton>
<Collapse in={expanded}>
<List
type="ordered"
size="xs"
mt={4}
pl="lg"
styles={{ itemWrapper: { lineHeight: 1.4 } }}
<div className="chat-completed-log__steps">
{progressLog.map((step, i) => {
const showConnector = i < progressLog.length - 1;
const stepLabel = formatProgress(step, t, resolveToolName);
return (
<div
key={i}
className="chat-progress-step chat-progress-step--done"
>
{names.map((name, i) => (
<List.Item key={i}>{name}</List.Item>
))}
</List>
<div className="chat-progress-step__left">
<div className="chat-progress-step__icon">
{progressStepIcon(step, resolveToolIcon, false)}
</div>
{showConnector && (
<div className="chat-progress-step__line" />
)}
</div>
<span className="chat-progress-step__label">{stepLabel}</span>
</div>
);
})}
</div>
</Collapse>
</Box>
</div>
);
}
@@ -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 (
<div className="chat-message chat-message-assistant">
<div className="chat-bubble-assistant">
{progressLog && progressLog.length > 0 && durationMs != null && (
<CompletedProgressLogDropdown
progressLog={progressLog}
durationMs={durationMs}
t={t}
resolveToolName={resolveToolName}
resolveToolIcon={resolveToolIcon}
/>
)}
<Text size="sm" component="div">
{renderMarkdown(content)}
</Text>
{toolsUsed && toolsUsed.length > 0 && (
<ToolsUsedBlock
tools={toolsUsed}
resolveToolName={resolveToolName}
t={t}
/>
)}
{actions}
</div>
</div>
@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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 && (
<div className="chat-message chat-message-assistant">
<div className="chat-thinking">
<StirlingLogoAnimated size={20} />
<Text size="sm" c="dimmed">
{progress
? formatProgress(progress, t, resolveToolName)
: t("chat.progress.thinking")}
</Text>
</div>
<ProgressLogDisplay
progressLog={progressLog}
t={t}
resolveToolName={resolveToolName}
resolveToolIcon={resolveToolIcon}
/>
</div>
)}
</Stack>