diff --git a/client/src/components/Chat/Messages/Content/AskUserQuestion.tsx b/client/src/components/Chat/Messages/Content/AskUserQuestion.tsx index 152ef96365..34f08f1142 100644 --- a/client/src/components/Chat/Messages/Content/AskUserQuestion.tsx +++ b/client/src/components/Chat/Messages/Content/AskUserQuestion.tsx @@ -62,6 +62,14 @@ export default function AskUserQuestion({ /** Live pause: share the hook's checked set so the composer's Enter and * this card submit exactly what the card displays. */ const checkedIndices = isLivePause ? answerMode.checked : localChecked; + const answerValue = isLivePause ? answerMode.answerText : answer; + const setAnswerValue = (value: string) => { + if (isLivePause) { + answerMode.setAnswerText(value); + return; + } + setAnswer(value); + }; const toggleIndex = (index: number) => { if (isLivePause) { answerMode.toggleChecked(index); @@ -72,7 +80,7 @@ export default function AskUserQuestion({ ); }; - const trimmed = answer.trim(); + const trimmed = answerValue.trim(); const canSubmit = multiSelect ? checkedIndices.length > 0 || trimmed.length > 0 : trimmed.length > 0; @@ -177,13 +185,13 @@ export default function AskUserQuestion({ )} setAnswer(e.target.value)} + onChange={(e) => setAnswerValue(e.target.value)} minRows={2} maxRows={12} placeholder={otherLabel ?? localize('com_ui_your_answer')} - className="w-full resize-none rounded-lg border border-border-light bg-surface-chat px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none" + className="w-full resize-none rounded-lg border border-border-light bg-surface-chat px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary" aria-label={localize('com_ui_your_answer')} /> diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index 58e36af45d..442f156ee9 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ChevronRight, Users } from 'lucide-react'; import { EModelEndpoint } from 'librechat-data-provider'; -import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import type { TAttachment, TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { SubagentRun } from '~/store/subagents'; import { @@ -60,6 +60,7 @@ export default function SubagentCall({ const runOverride: SubagentRun = { toolCallId, + isSubmitting, args, output, attachments, @@ -78,6 +79,7 @@ export default function SubagentCall({ if (!toolCallId) return; const signature = [ initialProgress, + isSubmitting, typeof args === 'string' ? args : JSON.stringify(args ?? null), output ?? '', persistedContent?.length ?? -1, @@ -91,23 +93,31 @@ export default function SubagentCall({ lastWrittenRef.current = signature; setRuns((prev) => ({ ...(prev ?? {}), - [toolCallId]: { toolCallId, args, output, attachments, persistedContent, initialProgress }, + [toolCallId]: { + toolCallId, + isSubmitting, + args, + output, + attachments, + persistedContent, + initialProgress, + }, })); - }, [toolCallId, args, output, attachments, persistedContent, initialProgress, setRuns]); + }, [ + toolCallId, + isSubmitting, + args, + output, + attachments, + persistedContent, + initialProgress, + setRuns, + ]); /** Auto-open the panel when a run first streams in — mirrors * `ToolArtifactCard`. `isSubmitting` is captured once at first render so a * history mount (page load, back-navigation) never steals focus. */ - const readInitialIsSubmitting = useRecoilCallback( - ({ snapshot }) => - () => - snapshot.getLoadable(store.isSubmittingFamily(0)).valueMaybe() ?? false, - [], - ); - const mountedDuringStreamRef = useRef(null); - if (mountedDuringStreamRef.current === null) { - mountedDuringStreamRef.current = readInitialIsSubmitting(); - } + const mountedDuringStreamRef = useRef(isSubmitting); const autoFocusedRef = useRef(false); useEffect(() => { if (!toolCallId || autoFocusedRef.current || !mountedDuringStreamRef.current) return; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx index e5a988e30d..c54a5e98ac 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx @@ -18,6 +18,8 @@ import { SUBAGENT_TICKER_THROTTLE_MS } from '../subagentShared'; import SubagentCall from '../SubagentCall'; import store from '~/store'; +const mockMCPServerNames: string[] = []; + jest.mock('~/hooks', () => ({ useLocalize: () => @@ -66,8 +68,7 @@ jest.mock('~/components/Share/MessageIcon', () => ({ })); jest.mock('~/hooks/MCP', () => { - const mcpServerNames: string[] = []; - return { useMCPServerNames: () => mcpServerNames }; + return { useMCPServerNames: () => mockMCPServerNames }; }); jest.mock('~/utils', () => ({ @@ -78,6 +79,7 @@ jest.mock('~/utils', () => ({ afterEach(() => { jest.useRealTimers(); + mockMCPServerNames.length = 0; }); function foldEvents(events: SubagentUpdateEvent[]): { @@ -273,6 +275,42 @@ describe('SubagentCall — inline preview', () => { expect(screen.getAllByText('Writing:')).toHaveLength(1); }); + it('preserves a configured MCP server boundary in the live ticker', () => { + mockMCPServerNames.push('Google_mcp_Workspace'); + renderWithState({ + toolCallId: 'call_mcp_ticker', + initialProgress: 0.3, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + status: 'run_step', + events: [ + { + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [ + { + id: 'c1', + name: 'search_documents_mcp_Google_mcp_Workspace', + }, + ], + }, + }, + timestamp: '', + } as SubagentUpdateEvent, + ], + }), + }); + + expect(screen.getByText('Google_mcp_Workspace')).toBeInTheDocument(); + }); + it('shows a one-line result summary from the final text once finished', () => { renderWithState({ toolCallId: 'call_summary', @@ -412,4 +450,16 @@ describe('SubagentCall — panel open contract', () => { }); expect(screen.getByTestId('current-run-id')).toHaveTextContent(''); }); + + it('does NOT auto-focus a historical run during an unrelated stream', () => { + renderWithState({ + toolCallId: 'call_unrelated_stream', + initialProgress: 0.3, + isSubmitting: false, + initializeStreaming: true, + progress: null, + }); + + expect(screen.getByTestId('current-run-id')).toHaveTextContent(''); + }); }); diff --git a/client/src/components/Chat/Messages/Content/Parts/subagentShared.tsx b/client/src/components/Chat/Messages/Content/Parts/subagentShared.tsx index 662b2ada0e..7ede75150b 100644 --- a/client/src/components/Chat/Messages/Content/Parts/subagentShared.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/subagentShared.tsx @@ -6,6 +6,7 @@ import type { SubagentUpdatePhase } from 'librechat-data-provider'; import type { SubagentTickerLine } from '~/utils/subagentContent'; import type { SubagentRun } from '~/store/subagents'; import { useAgentsMapContext } from '~/Providers'; +import { useMCPServerNames } from '~/hooks/MCP'; import { parseToolName } from '~/utils'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -150,11 +151,10 @@ export function useSubagentRunView( const registered = useRecoilValue(store.subagentRunByIdSelector(toolCallId)); const run = runOverride ?? registered; const progress = useRecoilValue(store.subagentProgressByToolCallId(toolCallId)); - /** The inline card passes its own `isSubmitting` prop (the message's stream - * state); the panel, rendered outside the message tree, omits it and falls - * back to the conversation's global submit atom. */ - const familyIsSubmitting = useRecoilValue(store.isSubmittingFamily(0)); - const isSubmitting = isSubmittingOverride ?? familyIsSubmitting; + /** Submission state belongs to this run. The detached panel cannot use the + * conversation-wide atom: another response may be streaming while a + * historical stopped run is selected. */ + const isSubmitting = isSubmittingOverride ?? run?.isSubmitting ?? false; const agentsMap = useAgentsMapContext(); const initialProgress = run?.initialProgress ?? 0; @@ -263,11 +263,13 @@ function ToolNameBadge({ name }: { name: string }): JSX.Element { function ToolIdentifier({ rawName, localize, + mcpServerNames, }: { rawName: string; localize: ReturnType; + mcpServerNames: readonly string[]; }): JSX.Element { - const parsed = parseToolName(rawName); + const parsed = parseToolName(rawName, mcpServerNames); if (parsed.mcpServer) { return ( @@ -291,6 +293,7 @@ function ToolIdentifier({ */ export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Element { const localize = useLocalize(); + const mcpServerNames = useMCPServerNames(); if (line.kind === 'writing' || line.kind === 'reasoning') { const prefix = line.kind === 'writing' @@ -312,7 +315,7 @@ export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Elem {line.toolNames.map((name, i) => ( {i > 0 && ,} - + ))} {line.argsSnippet && ( @@ -325,7 +328,11 @@ export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Elem if (line.kind === 'tool_complete') { return ( - + {line.outputSnippet ?? localize('com_ui_subagent_ticker_tool_done')} diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index ae4adb5d39..b33a3f59ff 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -283,9 +283,9 @@ export default function ToolCallGroup({ /** For a single-tool group, lead with the tool's own (capitalized) label * instead of the generic "Used 1 tool — name", which reads awkwardly. */ const singleToolLabel = useMemo(() => { - const raw = getToolDisplayLabel(toolMetadata[0]?.name ?? '', localize); + const raw = getToolDisplayLabel(toolMetadata[0]?.name ?? '', localize, mcpServerNames); return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : ''; - }, [toolMetadata, localize]); + }, [toolMetadata, localize, mcpServerNames]); const autoExpand = useRecoilValue(store.autoExpandTools); /** A labeled activity block is summarized by its header, so it collapses diff --git a/client/src/components/Chat/Messages/Content/__tests__/AskUserQuestion.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/AskUserQuestion.test.tsx new file mode 100644 index 0000000000..10e6668bac --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/AskUserQuestion.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import AskUserQuestion from '../AskUserQuestion'; + +const mockSubmitAnswer = jest.fn(); +const mockSetAnswerText = jest.fn(); + +jest.mock('@librechat/client', () => ({ + Button: ({ + children, + size: _size, + variant: _variant, + ...props + }: React.ButtonHTMLAttributes & { size?: string; variant?: string }) => ( + + ), + TextareaAutosize: ({ + minRows: _minRows, + maxRows: _maxRows, + ...props + }: React.TextareaHTMLAttributes & { + minRows?: number; + maxRows?: number; + }) =>