mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
show chat progress and other UX improvements (#6576)
This commit is contained in:
+15
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user