diff --git a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx index dd80e95d78..26176ba410 100644 --- a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx +++ b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { Terminal } from 'lucide-react'; +import type { ToolCallPhase } from '~/utils/toolCallPhase'; import { useProgress, useLocalize } from '~/hooks'; import ProgressText from './ProgressText'; import MarkdownLite from './MarkdownLite'; @@ -46,14 +47,22 @@ export default function CodeAnalyze({ return acc; }, ''); + /** + * The legacy assistants-endpoint card: it never receives run-step metadata, + * so it genuinely has only these two states and maps them directly rather + * than through `resolveToolCallPhase`, which needs signals this card has no + * access to. The announcement and the icon below read this same value. + */ + const phase: ToolCallPhase = progress < 1 ? 'running' : 'completed'; + return ( <> - {progress < 1 ? localize('com_ui_analyzing') : localize('com_ui_analyzing_finished')} + {phase === 'running' ? localize('com_ui_analyzing') : localize('com_ui_analyzing_finished')}
{output}
diff --git a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx
index 2e93348d1a..6d8440e1e0 100644
--- a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx
@@ -89,8 +89,14 @@ export default function ReadFileCall({
const fileName = filePath.split('/').pop() || filePath;
const lang = useMemo(() => langFromPath(filePath), [filePath]);
- const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
- useToolCallState(initialProgress, isSubmitting, output, !!filePath, onExpand, runStepStatus);
+ const { showCode, toggleCode, expandStyle, expandRef, phase, hasOutput } = useToolCallState({
+ initialProgress,
+ isSubmitting,
+ output,
+ hasInput: !!filePath,
+ onExpand,
+ runStepStatus,
+ });
const highlighted = useLazyHighlight(hasOutput ? output : undefined, lang);
@@ -98,28 +104,26 @@ export default function ReadFileCall({
<>
}
hasInput={!!filePath || hasOutput}
isExpanded={showCode}
- error={cancelled}
/>
diff --git a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx
index cd73e73223..55d1e8be0b 100644
--- a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx
@@ -35,35 +35,39 @@ export default function SkillCall({
const skillName = useMemo(() => parseJsonField(args, 'skillName'), [args]);
const intent = useToolCallIntent(args);
- const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
- useToolCallState(initialProgress, isSubmitting, output, !!skillName, onExpand, runStepStatus);
+ const { showCode, toggleCode, expandStyle, expandRef, phase, hasOutput } = useToolCallState({
+ initialProgress,
+ isSubmitting,
+ output,
+ hasInput: !!skillName,
+ onExpand,
+ runStepStatus,
+ });
return (
<>
}
hasInput={!!skillName || hasOutput}
isExpanded={showCode}
- error={cancelled}
/>
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
index 701159aa84..7775c80e02 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx
@@ -33,20 +33,20 @@ jest.mock('~/hooks', () => ({
jest.mock('~/components/Chat/Messages/Content/ProgressText', () => ({
__esModule: true,
+ /** Mirrors the real component's contract: one `phase` drives both the
+ * label and the failure suffix. */
default: ({
- progress,
+ phase,
inProgressText,
finishedText,
- errorSuffix,
}: {
- progress: number;
+ phase: 'running' | 'completed' | 'cancelled' | 'failed';
inProgressText: string;
finishedText: string;
- errorSuffix?: string;
}) => (
- {progress < 1 ? inProgressText : finishedText}
- {errorSuffix != null ? ` — ${errorSuffix}` : ''}
+ {phase === 'running' ? inProgressText : finishedText}
+ {phase === 'failed' ? ' — tool failed' : ''}
),
}));
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx
index ddfed3b06e..db8266c33a 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx
@@ -26,14 +26,16 @@ jest.mock('~/utils', () => ({
jest.mock('~/components/Chat/Messages/Content/ProgressText', () => ({
__esModule: true,
default: ({
- progress,
+ phase,
inProgressText,
finishedText,
}: {
- progress: number;
+ phase: 'running' | 'completed' | 'cancelled' | 'failed';
inProgressText: string;
finishedText: string;
- }) => {progress < 1 ? inProgressText : finishedText},
+ }) => (
+ {phase === 'running' ? inProgressText : finishedText}
+ ),
}));
jest.mock('../CodeWindowHeader', () => ({
@@ -64,14 +66,14 @@ jest.mock('../useLazyHighlight', () => {
jest.mock('../useToolCallState', () => ({
__esModule: true,
- default: (initialProgress: number) => ({
+ /** Mirrors the real hook: options object in, a single `phase` out. */
+ default: ({ initialProgress }: { initialProgress: number }) => ({
showCode: true,
toggleCode: jest.fn(),
expandStyle: {},
expandRef: { current: null },
progress: initialProgress,
- cancelled: false,
- hasError: false,
+ phase: initialProgress < 1 ? 'running' : 'completed',
}),
}));
diff --git a/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts
index 47483c33a4..18e0aae1cf 100644
--- a/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts
+++ b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts
@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import type { PartMetadata } from 'librechat-data-provider';
+import type { ToolCallPhase } from '~/utils/toolCallPhase';
import { isError } from '~/components/Chat/Messages/Content/ToolOutput';
+import { resolveToolCallPhase } from '~/utils/toolCallPhase';
import { useProgress, useExpandCollapse } from '~/hooks';
import store from '~/store';
@@ -10,24 +12,44 @@ interface ToolCallState {
toggleCode: () => void;
expandStyle: React.CSSProperties;
expandRef: React.RefObject;
- progress: number;
- cancelled: boolean;
- hasError: boolean;
+ /**
+ * The card's settled state, resolved once. Label, announcement, icon and
+ * animation all read this — deriving any of them separately is what let
+ * them disagree.
+ */
+ phase: ToolCallPhase;
hasOutput: boolean;
hasContent: boolean;
}
-export default function useToolCallState(
- initialProgress: number,
- isSubmitting: boolean,
- output: string,
- hasInput: boolean,
- onExpand?: () => void,
- runStepStatus?: PartMetadata['runStepStatus'],
-): ToolCallState {
+export interface UseToolCallStateInput {
+ initialProgress: number;
+ isSubmitting: boolean;
+ output: string;
+ hasInput: boolean;
+ onExpand?: () => void;
+ runStepStatus?: PartMetadata['runStepStatus'];
+ /**
+ * A failure this call's own output cannot express — currently a
+ * backgrounded task that settled as `error`, where the dispatch step's
+ * output is the handle rather than the task's result. Folded into the
+ * phase so those cards resolve their state through the same path as
+ * every other card instead of patching the result afterwards.
+ */
+ extraError?: boolean;
+}
+
+export default function useToolCallState({
+ initialProgress,
+ isSubmitting,
+ output,
+ hasInput,
+ onExpand,
+ runStepStatus,
+ extraError = false,
+}: UseToolCallStateInput): ToolCallState {
const autoExpand = useRecoilValue(store.autoExpandTools);
const hasOutput = output.length > 0;
- const hasError = (hasOutput && isError(output)) || runStepStatus === 'failed';
const hasContent = hasInput || hasOutput;
const [showCode, setShowCode] = useState(() => autoExpand && hasContent);
@@ -41,12 +63,13 @@ export default function useToolCallState(
const isClosed = runStepStatus != null;
/**
- * Both halves are load-bearing: passing 1 in stops `useProgress` scheduling
- * its 200ms interval, and masking the result makes the terminal value
- * observable on the same render rather than after the hook settles.
+ * Passing 1 in for a closed step stops `useProgress` scheduling its 200ms
+ * interval, which it keeps alive for any input below 1. The result no
+ * longer needs masking: the phase treats an explicit close as terminal
+ * outright, so a step that closes while the hook is still settling through
+ * 0.99 can no longer read as in-flight.
*/
const rawProgress = useProgress(isClosed ? 1 : initialProgress);
- const progress = isClosed ? 1 : rawProgress;
const toggleCode = useCallback(() => {
setShowCode((prev) => {
const next = !prev;
@@ -56,28 +79,26 @@ export default function useToolCallState(
return next;
});
}, [onExpand]);
+
/**
- * The step's own terminal status wins when the run emitted one; the
- * `isSubmitting` heuristic is a whole-message inference that cannot tell
- * which step stopped. Kept as the fallback for messages saved before
- * `on_run_step_closed` and endpoints that do not emit it.
- *
- * The status is authoritative on its own terms — it is deliberately not
- * gated on `hasError`, so that parsing the output text can never demote a
- * step the run reported as stopped back into an in-flight state.
+ * One resolution; everything the card shows is a read of this value. The
+ * two progress inputs are deliberately distinct — see `resolveToolCallPhase`
+ * for why the cancellation inference must not read the animated one.
*/
- const cancelled = isClosed
- ? runStepStatus === 'cancelled'
- : !isSubmitting && rawProgress < 1 && !hasError;
+ const phase = resolveToolCallPhase({
+ runStepStatus,
+ displayProgress: rawProgress,
+ reportedProgress: initialProgress,
+ isSubmitting,
+ hasError: (hasOutput && isError(output)) || extraError,
+ });
return {
showCode,
toggleCode,
expandStyle,
expandRef,
- progress,
- cancelled,
- hasError,
+ phase,
hasOutput,
hasContent,
};
diff --git a/client/src/components/Chat/Messages/Content/ProgressText.tsx b/client/src/components/Chat/Messages/Content/ProgressText.tsx
index 5ff5a79ba8..323d715ea9 100644
--- a/client/src/components/Chat/Messages/Content/ProgressText.tsx
+++ b/client/src/components/Chat/Messages/Content/ProgressText.tsx
@@ -3,6 +3,7 @@ import { Button } from '@librechat/client';
import { useTranslation } from 'react-i18next';
import * as Popover from '@radix-ui/react-popover';
import { isReportableRunStepDuration } from 'librechat-data-provider';
+import type { ToolCallPhase } from '~/utils/toolCallPhase';
import { cn, getRunStepDurationLabels } from '~/utils';
import CancelledIcon from './CancelledIcon';
import { useLocalize } from '~/hooks';
@@ -39,73 +40,60 @@ const Wrapper = ({ popover, children }: { popover: boolean; children: React.Reac
};
export default function ProgressText({
- progress,
+ phase,
onClick,
inProgressText,
finishedText,
authText,
icon: iconProp,
subtitle,
- errorSuffix,
durationMs,
hasInput = true,
popover = false,
isExpanded = false,
- error = false,
}: {
- progress: number;
+ /**
+ * The card's settled state, resolved once by the caller via
+ * `resolveToolCallPhase`. Replaces the former `error` + `errorSuffix`
+ * pair, which encoded three terminal states in two booleans — `error`
+ * meant cancelled, a present `errorSuffix` meant failed, and every
+ * consumer had to reconstruct the distinction. That shape is what let a
+ * duration render beside "failed" and a live region announce "completed"
+ * over a visibly failed card.
+ */
+ phase: ToolCallPhase;
onClick?: () => void;
inProgressText: string;
finishedText: string;
authText?: string;
icon?: React.ReactNode;
subtitle?: string;
- errorSuffix?: string;
/** Wall-clock duration of the run step, from `PartMetadata.runStepDurationMs`. */
durationMs?: number;
hasInput?: boolean;
popover?: boolean;
isExpanded?: boolean;
- error?: boolean;
}) {
const localize = useLocalize();
/** For locale-aware decimal formatting of the sub-10s duration value. */
const { i18n } = useTranslation();
- const getText = () => {
- if (error) {
- return finishedText;
- }
- if (progress < 1) {
- return authText ?? inProgressText;
- }
- return finishedText;
- };
+ const isRunning = phase === 'running';
- const getIcon = () => {
- if (error && !errorSuffix) {
- return ;
- }
- return iconProp ?? null;
- };
-
- const text = getText();
- const icon = getIcon();
- const showShimmer = progress < 1 && !error;
+ /** Every branch below reads `phase`, so the label, the icon, the shimmer,
+ * the failure suffix and the duration cannot disagree about what state
+ * the card is in. */
+ const text = isRunning ? (authText ?? inProgressText) : finishedText;
+ const icon = phase === 'cancelled' ? : (iconProp ?? null);
+ const showShimmer = isRunning;
+ const errorSuffix = phase === 'failed' ? localize('com_ui_tool_failed') : undefined;
/**
* Shown only on a settled, successful card. While the step is still running
* the number would be stale the instant it rendered, and on a cancelled or
* failed card "how long it took" is not the fact the reader needs — that
- * slot already carries the cancelled icon or the error suffix.
- *
- * Both terminal-failure channels must be checked: at every call site
- * `error` carries cancellation while failure arrives as `errorSuffix`
- * alone, so gating on `error` by itself would print a duration beside
- * "failed". Gating here on the component's own props rather than on a
- * separate caller-supplied flag keeps this consistent with the label
- * beside it by construction; the callers only forward the number.
+ * slot already carries the cancelled icon or the failure suffix.
*/
const duration =
- progress >= 1 && !error && !errorSuffix && isReportableRunStepDuration(durationMs)
+ phase === 'completed' && isReportableRunStepDuration(durationMs)
? getRunStepDurationLabels(durationMs, i18n.language)
: undefined;
diff --git a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx
index 9cbd74580c..73a1d67d20 100644
--- a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx
+++ b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx
@@ -6,6 +6,7 @@ import { FileText, FileSpreadsheet, FileCode, FileImage, File } from 'lucide-rea
import type { TAttachment, TFile, PartMetadata } from 'librechat-data-provider';
import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
import { ToolIcon, OutputRenderer, isError } from './ToolOutput';
+import { resolveToolCallPhase } from '~/utils/toolCallPhase';
import FilePreviewDialog from './FilePreviewDialog';
import { sortPagesByRelevance, cn } from '~/utils';
import { useToolCallIntent } from './Parts/intent';
@@ -350,25 +351,25 @@ export default function RetrievalCall({
* mounted would otherwise render as still in progress for that window.
*/
const rawProgress = useProgress(isClosed ? 1 : initialProgress);
- const progress = isClosed ? 1 : rawProgress;
const localize = useLocalize();
/** Model-authored live label (injected when file_search is opted into
* describe_intent); persists as the settled label. The sr-only live
* region below deliberately keeps its stable generic value. */
const intent = useToolCallIntent(args);
- const errorState = (typeof output === 'string' && isError(output)) || runStepStatus === 'failed';
/**
- * The step's own terminal status wins when the run emitted one; the
- * `isSubmitting` heuristic is a whole-message inference that cannot tell
- * which step stopped. Authoritative on its own terms — not gated on
- * `errorState`, so output parsing cannot demote a stopped step back into an
- * in-flight state. Fallback retained for messages saved before
- * `on_run_step_closed` and endpoints that do not emit it.
+ * One resolution, read by the label, the live region and the icon alike.
+ * It also unifies two inputs that had drifted apart: the cancellation
+ * inference read `initialProgress` while the label read the animated
+ * `rawProgress`.
*/
- const cancelled = isClosed
- ? runStepStatus === 'cancelled'
- : !isSubmitting && initialProgress < 1 && !errorState;
+ const phase = resolveToolCallPhase({
+ runStepStatus,
+ displayProgress: rawProgress,
+ reportedProgress: initialProgress,
+ isSubmitting,
+ hasError: typeof output === 'string' && isError(output),
+ });
const hasOutput = !!output && !isError(output);
const autoExpand = useRecoilValue(store.autoExpandTools);
const [showOutput, setShowOutput] = useState(() => autoExpand && hasOutput);
@@ -446,16 +447,16 @@ export default function RetrievalCall({
{(() => {
- if (progress < 1 && !cancelled) {
+ if (phase === 'running') {
return localize('com_ui_searching_files');
}
- if (cancelled) {
+ if (phase === 'cancelled') {
return localize('com_ui_cancelled');
}
- /** Announced before the success string: a terminal step that errored
- * must not reach the live region as "retrieved files", which would
- * tell a screen-reader user the opposite of what the card shows. */
- if (errorState) {
+ /** A terminal step that errored must not reach the live region as
+ * "retrieved files", which would tell a screen-reader user the
+ * opposite of what the card shows. */
+ if (phase === 'failed') {
return localize('com_ui_failed');
}
return intent ?? localize('com_ui_retrieved_files');
@@ -463,24 +464,20 @@ export default function RetrievalCall({
- }
+ icon={ }
hasInput={hasOutput}
isExpanded={showOutput}
- error={cancelled}
/>
diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx
index cdd5096692..25f0d3d3cc 100644
--- a/client/src/components/Chat/Messages/Content/ToolCall.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx
@@ -13,6 +13,7 @@ import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import { useLocalize, useProgress, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
import { ToolIcon, getToolIconType, isError } from './ToolOutput';
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
+import { resolveToolCallPhase } from '~/utils/toolCallPhase';
import { useToolCallIntent } from './Parts/intent';
import { AttachmentGroup } from './Parts';
import ToolCallInfo from './ToolCallInfo';
@@ -156,10 +157,6 @@ export default function ToolCall({
* in-flight state.
*/
const isClosed = runStepStatus != null;
- const cancelled = isClosed
- ? runStepStatus === 'cancelled'
- : !isSubmitting && initialProgress < 1 && !hasError;
- const errorState = hasError;
const args = useMemo(() => {
if (typeof _args === 'string') {
@@ -191,8 +188,19 @@ export default function ToolCall({
* observable on the same render rather than after the hook settles.
*/
const rawProgress = useProgress(isClosed ? 1 : initialProgress);
- const progress = isClosed ? 1 : rawProgress;
- const showCancelled = cancelled || (errorState && !output);
+ /**
+ * One resolution, read by the label, the live region, the icon and the
+ * shimmer alike. It also unifies two inputs that had drifted apart: the
+ * cancellation inference read `initialProgress` while the label read the
+ * animated `rawProgress`.
+ */
+ const phase = resolveToolCallPhase({
+ runStepStatus,
+ displayProgress: rawProgress,
+ reportedProgress: initialProgress,
+ isSubmitting,
+ hasError,
+ });
const handleToggleInfo = useCallback(() => {
mountBody();
@@ -221,7 +229,7 @@ export default function ToolCall({
const intent = useToolCallIntent(_args);
const getFinishedText = () => {
- if (cancelled) {
+ if (phase === 'cancelled') {
return localize('com_ui_cancelled');
}
/**
@@ -229,7 +237,7 @@ export default function ToolCall({
* errored must not reach the live region as "completed", which would tell
* a screen-reader user the opposite of what the card shows.
*/
- if (errorState) {
+ if (phase === 'failed') {
return function_name
? `${localize('com_ui_failed')}: ${function_name}`
: localize('com_ui_failed');
@@ -258,7 +266,7 @@ export default function ToolCall({
announced once via getFinishedText. */}
{(() => {
- if (progress < 1 && !showCancelled) {
+ if (phase === 'running') {
return function_name
? localize('com_assistants_running_var', { 0: function_name })
: localize('com_assistants_running_action');
@@ -272,7 +280,7 @@ export default function ToolCall({
data-tool-call-id={toolCallId}
>
0 ? localize('com_ui_requires_auth') : undefined
+ phase === 'running' && authDomain.length > 0
+ ? localize('com_ui_requires_auth')
+ : undefined
}
finishedText={getFinishedText()}
subtitle={subtitle}
durationMs={runStepDurationMs}
- errorSuffix={errorState && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
-
+
}
hasInput={hasInfo}
isExpanded={showInfo}
- error={showCancelled}
/>
- {auth != null && auth && progress < 1 && !showCancelled && (
+ {auth != null && auth && phase === 'running' && (