From b0a559876f3d0ca77886773fee484643a83ac87f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 13:53:16 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A9=20feat:=20Collapsible=20Wake-Up=20?= =?UTF-8?q?Task=20Cards=20and=20Subagent=20UI=20Consistency=20(#15364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿงฉ feat: Collapsible Wake-Up Task Cards and Subagent UI Consistency * ๐Ÿงฉ fix: Codex Round 1 โ€” Shared Composer Surface, Pinned Event Tasks, Durable Wake-Up Links * ๐Ÿงฉ fix: Codex Round 2 โ€” Pin Requested Event Tasks, Share Gating, Focus Return * ๐Ÿงฉ fix: Codex Round 3 โ€” Promote Composer Surface to @librechat/client Semantic Primitive --- client/src/components/Chat/Input/ChatForm.tsx | 13 +- .../components/Chat/Messages/Content/Part.tsx | 4 + .../Chat/Messages/Content/Parts/Reasoning.tsx | 13 +- .../Chat/Messages/Content/Parts/Thinking.tsx | 24 ++ .../Content/Parts/__tests__/wakeup.test.ts | 149 ++++++++++ .../Chat/Messages/Content/Parts/index.ts | 2 +- .../Chat/Messages/Content/Parts/wakeup.ts | 126 +++++++++ .../Chat/Messages/Content/Wakeup.tsx | 264 ++++++++++++++++++ .../Messages/Content/__tests__/Part.test.tsx | 26 ++ .../Content/__tests__/Wakeup.test.tsx | 174 ++++++++++++ .../Chat/Messages/ui/MessageRender.tsx | 39 ++- .../Chat/Messages/ui/MessageRow.tsx | 18 +- .../Messages/ui/__tests__/MessageRow.spec.tsx | 16 ++ .../Chat/Subagents/SubagentActivity.test.tsx | 26 +- .../Chat/Subagents/SubagentActivity.tsx | 87 +++--- .../Subagents/SubagentConversation.test.tsx | 4 +- .../Chat/Subagents/SubagentConversation.tsx | 38 ++- .../Subagents/SubagentThreadPanel.test.tsx | 10 +- .../Chat/Subagents/SubagentThreadPanel.tsx | 63 +++-- .../src/components/Chat/Subagents/adapters.ts | 5 +- .../Chat/Subagents/eventSelection.ts | 32 ++- client/src/locales/en/translation.json | 10 +- client/src/store/subagents.ts | 3 + packages/client/src/utils/composer.ts | 19 ++ packages/client/src/utils/index.ts | 1 + .../data-provider/src/types/assistants.ts | 3 + 26 files changed, 1055 insertions(+), 114 deletions(-) create mode 100644 client/src/components/Chat/Messages/Content/Parts/__tests__/wakeup.test.ts create mode 100644 client/src/components/Chat/Messages/Content/Parts/wakeup.ts create mode 100644 client/src/components/Chat/Messages/Content/Wakeup.tsx create mode 100644 client/src/components/Chat/Messages/Content/__tests__/Wakeup.test.tsx create mode 100644 packages/client/src/utils/composer.ts diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 0a1266216a..1190a9fc96 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -1,8 +1,8 @@ import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react'; import { useWatch } from 'react-hook-form'; -import { TextareaAutosize } from '@librechat/client'; import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil'; import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; +import { composerSurfaceClasses, composerSurfaceShadow, TextareaAutosize } from '@librechat/client'; import type { TChatProject, TMessage, TConversation } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common'; import type { QueuedMessageContext } from '~/hooks/Chat/useSteering'; @@ -594,11 +594,12 @@ const ChatForm = memo(function ChatForm({
{project ? : null} diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 70a7ade590..a21b5f9920 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -14,6 +14,7 @@ import { AgentUpdate, EmptyText, Reasoning, + ReasoningMarker, Summary, Text, SkillCall, @@ -145,6 +146,9 @@ const Part = memo(function Part({ if (typeof reasoning !== 'string') { return null; } + if (reasoning.trim() === '' && part.reasoning_unavailable === true) { + return ; + } return ( { + const localize = useLocalize(); + const display = label?.trim() || localize('com_ui_thoughts'); + return ; +}); + +ReasoningMarker.displayName = 'ReasoningMarker'; + const Reasoning = memo((props: ReasoningProps) => { const { reasoning, isLast, reasoningLabel } = props; const contentId = useId(); diff --git a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx index aca8d9a4b8..65c3bf8ce5 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx @@ -140,6 +140,29 @@ export const ThinkingButton = memo( }, ); +/** + * ThinkingLabel - Non-interactive variant of the ThinkingButton header row, + * for reasoning that happened but whose text is not available to this view + * (detached subagent projections retain only a marker). Keeps the reasoning + * presentation identical across surfaces without offering an empty disclosure. + */ +export const ThinkingLabel = memo(({ label, title }: { label: string; title?: string }) => { + const fontSize = useAtomValue(fontSizeAtom); + return ( +
+
+ + + {label} +
+
+ ); +}); + /** * FloatingThinkingBar - Floating bar with expand/collapse and copy buttons * Shows on hover/focus, positioned at bottom right of thinking content @@ -348,6 +371,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN ThinkingButton.displayName = 'ThinkingButton'; ThinkingContent.displayName = 'ThinkingContent'; +ThinkingLabel.displayName = 'ThinkingLabel'; FloatingThinkingBar.displayName = 'FloatingThinkingBar'; Thinking.displayName = 'Thinking'; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/wakeup.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/wakeup.test.ts new file mode 100644 index 0000000000..682a2d62a3 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/wakeup.test.ts @@ -0,0 +1,149 @@ +import { parseWakeupText } from '../wakeup'; + +const subagentText = [ + 'A detached subagent task has completed. Continue the parent task using its durable result below.', + JSON.stringify({ + background_task_id: 'task-1', + subagent_thread_id: 'thread-1', + subagent_type: 'self', + status: 'completed', + result: '## Daily briefing\nAll clear.', + }), + 'Host-authored bounded orchestration snapshot:', + JSON.stringify({ scope: 'current_parent_branch', known_children: [] }), +].join('\n'); + +const backgroundText = [ + 'A background tool task has finished. Continue using its durable result below.', + JSON.stringify([ + { + background_task_id: 'bg-1', + tool_call_id: 'call-1', + tool: 'web_search', + status: 'completed', + result: 'Found 3 sources.', + }, + ]), +].join('\n'); + +describe('parseWakeupText', () => { + it('parses a subagent completion wake-up into one display task', () => { + const display = parseWakeupText(subagentText); + expect(display).toEqual({ + kind: 'subagent', + tasks: [ + { + taskId: 'task-1', + status: 'completed', + result: '## Daily briefing\nAll clear.', + threadId: 'thread-1', + subagentType: 'self', + }, + ], + }); + }); + + it.each(['error', 'cancelled'] as const)('parses a subagent %s wake-up', (status) => { + const text = [ + `A detached subagent task has ${status}. Continue the parent task using its durable result below.`, + JSON.stringify({ + background_task_id: 'task-1', + subagent_thread_id: 'thread-1', + subagent_type: 'researcher', + status, + result: '', + }), + ].join('\n'); + expect(parseWakeupText(text)?.tasks[0]?.status).toBe(status); + }); + + it('parses a single background tool wake-up', () => { + const display = parseWakeupText(backgroundText); + expect(display).toEqual({ + kind: 'background_tool', + tasks: [ + { + taskId: 'bg-1', + status: 'completed', + result: 'Found 3 sources.', + toolCallId: 'call-1', + toolName: 'web_search', + }, + ], + }); + }); + + it('parses a plural background tool wake-up', () => { + const text = [ + '2 background tool tasks have finished. Continue using their durable results below.', + JSON.stringify([ + { + background_task_id: 'bg-1', + tool_call_id: 'call-1', + tool: 'web_search', + status: 'completed', + result: 'ok', + }, + { + background_task_id: 'bg-2', + tool_call_id: 'call-2', + tool: 'execute_code', + status: 'error', + result: 'boom', + }, + ]), + ].join('\n'); + const display = parseWakeupText(text); + expect(display?.kind).toBe('background_tool'); + expect(display?.tasks).toHaveLength(2); + expect(display?.tasks[1]).toMatchObject({ status: 'error', toolName: 'execute_code' }); + }); + + it('rejects ordinary user text', () => { + expect(parseWakeupText('Please summarize the detached subagent task results.')).toBeNull(); + expect(parseWakeupText('')).toBeNull(); + expect(parseWakeupText(undefined)).toBeNull(); + }); + + it('rejects a quoted wake-up prompt that does not start the message', () => { + expect(parseWakeupText(`Look at this:\n${subagentText}`)).toBeNull(); + }); + + it('rejects a header whose payload is not valid JSON', () => { + expect( + parseWakeupText( + 'A detached subagent task has completed. Continue the parent task using its durable result below.\nnot json', + ), + ).toBeNull(); + }); + + it('rejects a payload whose status disagrees with the header', () => { + const text = [ + 'A detached subagent task has completed. Continue the parent task using its durable result below.', + JSON.stringify({ + background_task_id: 'task-1', + subagent_thread_id: 'thread-1', + subagent_type: 'self', + status: 'error', + result: '', + }), + ].join('\n'); + expect(parseWakeupText(text)).toBeNull(); + }); + + it('rejects a payload missing required identity fields', () => { + const text = [ + 'A background tool task has finished. Continue using its durable result below.', + JSON.stringify([{ background_task_id: 'bg-1', status: 'completed', result: 'ok' }]), + ].join('\n'); + expect(parseWakeupText(text)).toBeNull(); + }); + + it('rejects an empty background payload array', () => { + expect( + parseWakeupText( + 'A background tool task has finished. Continue using its durable result below.\n[]', + ), + ).toBeNull(); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts index 2deb26ef2e..0127e6ec2a 100644 --- a/client/src/components/Chat/Messages/Content/Parts/index.ts +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -3,7 +3,7 @@ export * from './OpenAIImageGen'; export { default as Text } from './Text'; export { default as CollapsibleText } from './CollapsibleText'; -export { default as Reasoning } from './Reasoning'; +export { default as Reasoning, ReasoningMarker } from './Reasoning'; export { default as EmptyText } from './EmptyText'; export { default as LogContent } from './LogContent'; export { default as ExecuteCode } from './ExecuteCode'; diff --git a/client/src/components/Chat/Messages/Content/Parts/wakeup.ts b/client/src/components/Chat/Messages/Content/Parts/wakeup.ts new file mode 100644 index 0000000000..ce46d45990 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/wakeup.ts @@ -0,0 +1,126 @@ +export type WakeupTaskStatus = 'completed' | 'error' | 'cancelled'; + +export type WakeupTask = { + taskId: string; + status: WakeupTaskStatus; + result: string; + /** Durable child-thread identity โ€” present for subagent completions. */ + threadId?: string; + subagentType?: string; + /** Parent tool-call identity โ€” present for background tool completions. */ + toolCallId?: string; + toolName?: string; +}; + +export type WakeupDisplay = { + kind: 'subagent' | 'background_tool'; + tasks: WakeupTask[]; +}; + +/** Mirrors `renderWakeupInput` in `packages/api/src/agents/subagentCompletionWakeup.ts`. */ +const SUBAGENT_WAKEUP_HEADER = + /^A detached subagent task has (completed|error|cancelled)\. Continue the parent task using its durable result below\.\n/; + +/** Mirrors `buildWakeupInput` in `packages/api/src/agents/backgroundCompletionWakeup.ts`. */ +const BACKGROUND_WAKEUP_HEADER = + /^(?:A background tool task has finished\. Continue using its durable result below\.|\d+ background tool tasks have finished\. Continue using their durable results below\.)\n/; + +const MAX_WAKEUP_TEXT_CHARS = 512 * 1024; + +const isRecord = (value: unknown): value is Record => + value != null && typeof value === 'object' && !Array.isArray(value); + +const wakeupStatus = (value: unknown): WakeupTaskStatus | null => + value === 'completed' || value === 'error' || value === 'cancelled' ? value : null; + +const parsePayloadLine = (body: string): unknown => { + const payloadLine = body.split('\n', 1)[0] ?? ''; + try { + return JSON.parse(payloadLine) as unknown; + } catch { + return null; + } +}; + +const subagentWakeupTask = (payload: unknown): WakeupTask | null => { + if (!isRecord(payload)) { + return null; + } + const status = wakeupStatus(payload.status); + if ( + status == null || + typeof payload.background_task_id !== 'string' || + typeof payload.subagent_thread_id !== 'string' || + typeof payload.subagent_type !== 'string' || + typeof payload.result !== 'string' + ) { + return null; + } + return { + taskId: payload.background_task_id, + status, + result: payload.result, + threadId: payload.subagent_thread_id, + subagentType: payload.subagent_type, + }; +}; + +const backgroundWakeupTask = (payload: unknown): WakeupTask | null => { + if (!isRecord(payload)) { + return null; + } + const status = wakeupStatus(payload.status); + if ( + status == null || + status === 'cancelled' || + typeof payload.background_task_id !== 'string' || + typeof payload.tool_call_id !== 'string' || + typeof payload.tool !== 'string' || + typeof payload.result !== 'string' + ) { + return null; + } + return { + taskId: payload.background_task_id, + status, + result: payload.result, + toolCallId: payload.tool_call_id, + toolName: payload.tool, + }; +}; + +/** + * Detects a host-authored wake-up continuation message (a detached subagent or + * background tool task settling and resuming the parent run) so the UI can + * render a task card instead of the model-facing prompt JSON. The strict + * header + payload shape check is intentional: ordinary user text quoting one + * of these prompts mid-message must never collapse into a card. + */ +export function parseWakeupText(text?: string | null): WakeupDisplay | null { + if (!text || text.length > MAX_WAKEUP_TEXT_CHARS) { + return null; + } + + const subagentHeader = SUBAGENT_WAKEUP_HEADER.exec(text); + if (subagentHeader != null) { + const task = subagentWakeupTask(parsePayloadLine(text.slice(subagentHeader[0].length))); + if (task == null || task.status !== subagentHeader[1]) { + return null; + } + return { kind: 'subagent', tasks: [task] }; + } + + const backgroundHeader = BACKGROUND_WAKEUP_HEADER.exec(text); + if (backgroundHeader == null) { + return null; + } + const payload = parsePayloadLine(text.slice(backgroundHeader[0].length)); + if (!Array.isArray(payload) || payload.length === 0) { + return null; + } + const tasks = payload.map(backgroundWakeupTask); + if (tasks.some((task) => task == null)) { + return null; + } + return { kind: 'background_tool', tasks: tasks as WakeupTask[] }; +} diff --git a/client/src/components/Chat/Messages/Content/Wakeup.tsx b/client/src/components/Chat/Messages/Content/Wakeup.tsx new file mode 100644 index 0000000000..0a5985f2e2 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Wakeup.tsx @@ -0,0 +1,264 @@ +import { memo, useCallback, useMemo, useState } from 'react'; +import { Button } from '@librechat/client'; +import { ChevronDown, Users } from 'lucide-react'; +import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; +import type { WakeupDisplay, WakeupTask } from './Parts/wakeup'; +import type { ActiveSubagentPanel } from '~/store/subagents'; +import type { TranslationKeys } from '~/hooks'; +import { subagentStatusIcon, subagentStatusLabelKey } from '~/components/Chat/Subagents/status'; +import { useParentSubagents } from '~/components/Chat/Subagents/ParentSubagentsProvider'; +import { durableSubagentSelection } from '~/components/Chat/Subagents/eventSelection'; +import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks'; +import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP'; +import { useShareContext } from '~/Providers/ShareContext'; +import { activeSubagentPanel } from '~/store/subagents'; +import { cn, getToolDisplayLabel } from '~/utils'; +import { useMessageContext } from '~/Providers'; +import { StackedToolIcons } from './ToolOutput'; +import MarkdownLite from './MarkdownLite'; +import store from '~/store'; + +const SUBAGENT_HEADER_KEYS = { + completed: 'com_ui_wakeup_subagent_completed', + error: 'com_ui_wakeup_subagent_errored', + cancelled: 'com_ui_wakeup_subagent_cancelled', +} as const satisfies Record; + +const threadStatus = (status: WakeupTask['status']) => + status === 'error' ? ('failed' as const) : status; + +function WakeupTaskCard({ + task, + kind, + conversationId, +}: { + task: WakeupTask; + kind: WakeupDisplay['kind']; + conversationId?: string | null; +}) { + const localize = useLocalize(); + const mcpServerNames = useMCPServerNames(); + const { isSharedConvo } = useShareContext(); + const { messageId } = useMessageContext(); + const { byThreadId } = useParentSubagents(); + const setSelection = useSetRecoilState(activeSubagentPanel); + const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); + const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const child = task.threadId == null ? undefined : byThreadId.get(task.threadId); + const selection = useMemo(() => { + /** Share pages have no authenticated durable-thread panel; a conversation + * selection there would be written and silently ignored. */ + if ( + isSharedConvo === true || + task.threadId == null || + conversationId == null || + conversationId === '' + ) { + return null; + } + if (child != null) { + return durableSubagentSelection(conversationId, child, task.taskId); + } + /** The bounded discovery index can omit older children; the wake-up payload + * already carries the exact durable identities, so link to the authorized + * thread query directly instead of requiring index membership. */ + return { + host: 'conversation', + parentConversationId: conversationId, + parentMessageId: messageId, + toolCallId: `wakeup:${task.threadId}`, + partIndex: 0, + subagentType: task.subagentType ?? '', + initialProgress: task.status === 'completed' ? 1 : 0, + isSubmitting: false, + durable: { threadId: task.threadId, taskId: task.taskId }, + }; + }, [child, conversationId, isSharedConvo, messageId, task]); + const status = threadStatus(task.status); + const StatusIcon = subagentStatusIcon(status); + const title = + kind === 'subagent' + ? (task.subagentType ?? '') + : getToolDisplayLabel(task.toolName ?? '', localize, mcpServerNames); + const hasResult = task.result.trim() !== ''; + + const openActivity = useCallback(() => { + if (selection == null) return; + resetCurrentArtifactId(); + setArtifactsVisible(false); + setSelection(selection); + }, [resetCurrentArtifactId, selection, setArtifactsVisible, setSelection]); + + return ( +
+
+ + {title !== '' && {title}} + {localize(subagentStatusLabelKey(status))} + {selection != null && ( + /** The trigger identity attributes let the panel's close handler + * return keyboard focus to this button. */ + + )} +
+ {hasResult && ( +
+ +
+ )} +
+ ); +} + +/** + * Collapsible task card for a host-authored wake-up continuation: the durable + * result that woke this agent, rendered in the tool-call visual family instead + * of the raw model-facing prompt. + */ +const Wakeup = memo(function Wakeup({ + display, + conversationId, +}: { + display: WakeupDisplay; + conversationId?: string | null; +}) { + const localize = useLocalize(); + const mcpIconMap = useMCPIconMap(); + const mcpServerNames = useMCPServerNames(); + const autoExpand = useRecoilValue(store.autoExpandTools); + const [isExpanded, setIsExpanded] = useState(autoExpand); + const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded); + const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(isExpanded); + + const handleToggle = useCallback(() => { + mountBody(); + setIsExpanded((previous) => !previous); + }, [mountBody]); + + const anyFailed = display.tasks.some((task) => task.status === 'error'); + const headerLabel = useMemo(() => { + if (display.kind === 'subagent') { + const status = display.tasks[0]?.status ?? 'completed'; + return localize(SUBAGENT_HEADER_KEYS[status]); + } + if (display.tasks.length > 1) { + return localize('com_ui_wakeup_tasks_finished', { 0: String(display.tasks.length) }); + } + return localize( + display.tasks[0]?.status === 'error' + ? 'com_ui_wakeup_task_errored' + : 'com_ui_wakeup_task_finished', + ); + }, [display.kind, display.tasks, localize]); + + const nameSummary = useMemo(() => { + if (display.kind === 'subagent') { + return display.tasks[0]?.subagentType ?? ''; + } + const seen = new Set(); + const labels: string[] = []; + for (const task of display.tasks) { + if (task.toolName == null || task.toolName === '') continue; + const label = getToolDisplayLabel(task.toolName, localize, mcpServerNames); + if (seen.has(label)) continue; + seen.add(label); + labels.push(label); + } + if (labels.length > 3) { + return `${labels.slice(0, 3).join(', ')}, +${labels.length - 3}`; + } + return labels.join(', '); + }, [display.kind, display.tasks, localize, mcpServerNames]); + + const toolIconNames = useMemo( + () => display.tasks.map((task) => task.toolName ?? ''), + [display.tasks], + ); + + return ( +
+ +
+ {shouldRenderBody && ( +
+
+
+ {localize('com_ui_wakeup_explainer')} +
+ {display.tasks.map((task) => ( + + ))} +
+
+ )} +
+
+ ); +}); + +export default Wakeup; diff --git a/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx index 5fd1498287..7d833745d8 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx @@ -10,6 +10,9 @@ jest.mock('../Parts', () => ({ AgentUpdate: () =>
, EmptyText: () =>
, Reasoning: () =>
, + ReasoningMarker: ({ label }: { label?: string }) => ( +
{label}
+ ), Summary: () =>
, Text: ({ text }: { text?: string }) =>
{text}
, SkillCall: () =>
, @@ -131,4 +134,27 @@ describe('Part tool renderer selection', () => { ); expect(screen.queryByTestId('tool-call')).not.toBeInTheDocument(); }); + + it('routes an unavailable reasoning marker to the marker renderer', () => { + renderPart({ + type: ContentTypes.THINK, + think: '', + reasoning_unavailable: true, + reasoning_label: 'Planning the answer', + } as TMessageContentParts); + + expect(screen.getByTestId('reasoning-marker')).toHaveTextContent('Planning the answer'); + expect(screen.queryByTestId('reasoning')).not.toBeInTheDocument(); + }); + + it('keeps reasoning with text on the full Reasoning renderer even when marked unavailable', () => { + renderPart({ + type: ContentTypes.THINK, + think: 'Actual thoughts', + reasoning_unavailable: true, + } as TMessageContentParts); + + expect(screen.getByTestId('reasoning')).toBeInTheDocument(); + expect(screen.queryByTestId('reasoning-marker')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/Wakeup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Wakeup.test.tsx new file mode 100644 index 0000000000..e61d2fec83 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/Wakeup.test.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ParentSubagentSummary } from 'librechat-data-provider'; +import { activeSubagentPanel } from '~/store/subagents'; +import Wakeup from '../Wakeup'; + +/** The hooks barrel drags the full data-provider graph into jsdom, so only + * localization is faked; the collapse hooks the card depends on stay real. */ +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string, vars?: Record) => + vars == null ? key : `${key}:${Object.values(vars).join(',')}`, + useExpandCollapse: jest.requireActual('~/hooks/Messages/useExpandCollapse').default, + useLazyCollapseBody: jest.requireActual('~/hooks/Messages/useLazyCollapseBody').default, +})); + +jest.mock('~/hooks/MCP', () => ({ + useMCPIconMap: () => ({}), + useMCPServerNames: () => ({}), +})); + +jest.mock('../ToolOutput', () => ({ + StackedToolIcons: () =>
, +})); + +jest.mock('../MarkdownLite', () => ({ + __esModule: true, + default: ({ content }: { content: string }) =>
{content}
, +})); + +jest.mock('lucide-react', () => ({ + AlertCircle: () => null, + CheckCircle2: () => null, + ChevronDown: () => null, + Clock3: () => null, + Users: () => null, + XCircle: () => null, +})); + +jest.mock('@librechat/client', () => ({ + Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), + useMediaQuery: () => false, +})); + +const child: ParentSubagentSummary = { + threadId: 'thread-1', + parentMessageId: 'parent-message', + parentToolCallId: 'tool-call-1', + subagentType: 'self', + subagentKind: 'agent', + origin: 'tool', + status: 'completed', + latestTaskId: 'task-1', + tasks: [{ taskId: 'task-1', status: 'completed', createdAt: '2026-08-30T00:00:00.000Z' }], + tasksTruncated: false, + title: 'Subagent: self', +} as ParentSubagentSummary; + +jest.mock('~/components/Chat/Subagents/ParentSubagentsProvider', () => ({ + useParentSubagents: () => ({ + byThreadId: new Map([['thread-1', child]]), + byMessageId: new Map(), + refresh: async () => undefined, + }), +})); + +function SelectionProbe() { + const selection = useRecoilValue(activeSubagentPanel); + return
{selection == null ? '' : selection.durable?.taskId}
; +} + +const subagentDisplay = { + kind: 'subagent' as const, + tasks: [ + { + taskId: 'task-1', + status: 'completed' as const, + result: '## Briefing\nAll clear.', + threadId: 'thread-1', + subagentType: 'self', + }, + ], +}; + +describe('Wakeup', () => { + it('renders a collapsible subagent completion card with the result and panel affordance', () => { + render( + + + + , + ); + + const header = screen.getByRole('button', { name: 'com_ui_wakeup_subagent_completed' }); + expect(header).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByTestId('markdown')).not.toBeInTheDocument(); + fireEvent.click(header); + expect(header).toHaveAttribute('aria-expanded', 'true'); + + expect(screen.getByTestId('markdown')).toHaveTextContent('Briefing'); + expect(screen.getByText('com_ui_wakeup_explainer')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_thread_status_completed')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'com_ui_wakeup_view_activity' })); + expect(screen.getByTestId('selection')).toHaveTextContent('task-1'); + }); + + it('keeps the panel affordance for a thread omitted from the bounded index', () => { + render( + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'com_ui_wakeup_subagent_completed' })); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_wakeup_view_activity' })); + expect(screen.getByTestId('selection')).toHaveTextContent('task-9'); + }); + + it('renders a failed background tool batch with per-task statuses and no panel affordance', () => { + render( + + + , + ); + + const header = screen.getByRole('button', { name: 'com_ui_wakeup_tasks_finished:2' }); + fireEvent.click(header); + expect(screen.getByTestId('stacked-tool-icons')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_thread_status_completed')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_thread_status_failed')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'com_ui_wakeup_view_activity' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index 355ae59c2c..73bd0a1825 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -9,6 +9,7 @@ import { getMessageAriaLabel, } from '~/utils'; import { revealOnRowHoverClasses, messageFooterClasses } from '~/components/Chat/Messages/styles'; +import { parseWakeupText } from '~/components/Chat/Messages/Content/Parts/wakeup'; import Elapsed, { shouldShowElapsed } from '~/components/Chat/Messages/Elapsed'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel'; @@ -17,6 +18,7 @@ import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; +import Wakeup from '~/components/Chat/Messages/Content/Wakeup'; import SubRow from '~/components/Chat/Messages/SubRow'; import { MessageContext } from '~/Providers'; import store from '~/store'; @@ -130,6 +132,10 @@ const MessageRender = memo(function MessageRender({ ); const { hasParallelContent } = useContentMetadata(msg); + const wakeupDisplay = useMemo( + () => (msg?.isCreatedByUser === true ? parseWakeupText(msg.text) : null), + [msg?.isCreatedByUser, msg?.text], + ); const messageId = msg?.messageId ?? ''; const messageContextValue = useMemo( () => ({ @@ -164,6 +170,7 @@ const MessageRender = memo(function MessageRender({ hasParallelContent={hasParallelContent} fullWidth={maximizeChatSpace} isEditing={edit} + plain={wakeupDisplay != null && !edit} footer={ {/* A user turn is right-aligned, so its retry navigation belongs at the @@ -207,20 +214,24 @@ const MessageRender = memo(function MessageRender({ } > - ({}))} - /> + {wakeupDisplay != null && !edit ? ( + + ) : ( + ({}))} + /> + )} ); diff --git a/client/src/components/Chat/Messages/ui/MessageRow.tsx b/client/src/components/Chat/Messages/ui/MessageRow.tsx index 0aadb57df8..dd3b20dd51 100644 --- a/client/src/components/Chat/Messages/ui/MessageRow.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRow.tsx @@ -17,6 +17,9 @@ type MessageRowProps = { hasParallelContent?: boolean; fullWidth?: boolean; isEditing?: boolean; + /** Full-width block without the author header or user bubble โ€” for rows + * whose body carries its own header (e.g. wake-up task cards). */ + plain?: boolean; className?: string; }; @@ -47,6 +50,7 @@ export default function MessageRow({ hasParallelContent = false, fullWidth = false, isEditing = false, + plain = false, }: MessageRowProps) { // Same column as ChatForm: max-width plus `sm:px-2`, so the body lines // up with the composer surface rather than the form's outer box. @@ -60,7 +64,7 @@ export default function MessageRow({ className={cn( 'message-render group mx-auto flex min-w-0 flex-1 font-theme-ui transition-[max-width] duration-theme-normal motion-reduce:transition-none', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary', - isCreatedByUser ? 'justify-end' : 'items-start', + isCreatedByUser && !plain ? 'justify-end' : 'items-start', widthClass, className, )} @@ -69,14 +73,16 @@ export default function MessageRow({ className={cn( 'relative flex min-w-0 flex-col', isCreatedByUser ? 'user-turn' : 'agent-turn', - (hasParallelContent || isEditing) && 'w-full', + (hasParallelContent || isEditing || plain) && 'w-full', !hasParallelContent && + !plain && isCreatedByUser && cn('ml-auto items-end', !isEditing && 'w-fit max-w-[90%] sm:max-w-[85%]'), !hasParallelContent && !isCreatedByUser && !isEditing && 'flex-1', )} > {!hasParallelContent && + !plain && (isCreatedByUser ? (

{headerPrefix} @@ -98,11 +104,11 @@ export default function MessageRow({

))} -
+
{children}
-
{footer}
+
+ {footer} +
diff --git a/client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx b/client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx index 782606d91d..a0e61ad2fd 100644 --- a/client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx +++ b/client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx @@ -15,11 +15,13 @@ const renderRow = ({ hasParallelContent = false, fullWidth = false, isEditing = false, + plain = false, }: { isCreatedByUser: boolean; hasParallelContent?: boolean; fullWidth?: boolean; isEditing?: boolean; + plain?: boolean; }) => render(

{MESSAGE_BODY}

, ); describe('MessageRow', () => { + it('renders a plain user row as a full-width block without header or bubble', () => { + renderRow({ isCreatedByUser: true, plain: true }); + + const row = screen.getByLabelText('User message'); + const messageSurface = screen.getByText(MESSAGE_BODY).parentElement; + + expect(row).not.toHaveClass('justify-end'); + expect(messageSurface).not.toHaveClass('bg-surface-tertiary'); + expect(messageSurface).toHaveClass('w-full'); + expect(screen.queryByRole('heading', { hidden: true })).not.toBeInTheDocument(); + expect(screen.getByTestId('message-actions')).toBeInTheDocument(); + }); + it('renders user content as a right-aligned semantic surface without a visible avatar', () => { renderRow({ isCreatedByUser: true }); diff --git a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx index 7ab4a52815..f1f244d942 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx @@ -1,9 +1,12 @@ import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { act, fireEvent, render as rtlRender, screen } from '@testing-library/react'; import type { Agents } from 'librechat-data-provider'; import type { ChildActivity } from './adapters'; import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity'; +const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: RecoilRoot }); + jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, })); @@ -56,6 +59,9 @@ jest.mock('~/components/Chat/Messages/Content/ContentParts', () => ({ ); } if (part.type === 'think') { + if ((part as { reasoning_unavailable?: boolean }).reasoning_unavailable === true) { + return
{`reasoning-marker:${part.reasoning_label ?? ''}`}
; + } return
{part.reasoning_label ?? part.think}
; } if (part.type === 'activity_label') { @@ -173,8 +179,8 @@ jest.mock('@librechat/client', () => ({ jest.mock('lucide-react', () => ({ AlertCircle: () => null, - ArrowDown: () => null, CheckCircle2: () => null, + ChevronDown: () => null, Clock3: () => null, Maximize2: () => null, Minimize2: () => null, @@ -488,7 +494,21 @@ describe('SubagentActivity', () => { ); expect(screen.getByTestId('regular-content-parts')).toBeInTheDocument(); - expect(screen.getByText('com_ui_subagent_ticker_reasoning')).toBeInTheDocument(); + expect(screen.getByText('reasoning-marker:')).toBeInTheDocument(); + }); + + it('keeps the display-safe reasoning label on a sanitized marker', () => { + render( + , + ); + + expect(screen.getByText('reasoning-marker:Planning the answer')).toBeInTheDocument(); }); it('uses the regular thinking cursor without running-state prose', () => { diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx index 350eda3536..3b93c5802b 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { Button } from '@librechat/client'; import { ContentTypes } from 'librechat-data-provider'; -import { ArrowDown, CheckCircle2, Clock3, Maximize2, Minimize2, XCircle } from 'lucide-react'; +import { CSSTransition } from 'react-transition-group'; +import { CheckCircle2, Clock3, Maximize2, Minimize2, XCircle } from 'lucide-react'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { ChildActivity, ChildActivityItem } from './adapters'; import type { TranslationKeys } from '~/hooks'; @@ -10,8 +12,10 @@ import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; import { subagentStatusIcon, subagentStatusLabelKey } from './status'; import Container from '~/components/Chat/Messages/Content/Container'; import { EmptyText } from '~/components/Chat/Messages/Content/Parts'; +import ScrollToBottom from '~/components/Messages/ScrollToBottom'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; +import store from '~/store'; const AT_BOTTOM_THRESHOLD_PX = 120; const CONTROL_ACTION_LABELS = { @@ -122,10 +126,12 @@ export function SubagentActivityScrollSurface({ children: React.ReactNode; padded?: boolean; }) { - const localize = useLocalize(); const scrollRef = useRef(null); const contentRef = useRef(null); + const scrollButtonRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); + const [isSettled, setIsSettled] = useState(false); + const scrollButtonPreference = useRecoilValue(store.showScrollButton); useEffect(() => { const scroll = scrollRef.current; @@ -145,39 +151,45 @@ export function SubagentActivityScrollSurface({ ); }, []); + const scrollToBottom = useCallback(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: 'smooth', + }); + setIsAtBottom(true); + }, []); + return ( -
- {!isAtBottom && ( - - )} -
{children}
+
+
+
{children}
+
+ setIsSettled(true)} + onExit={() => setIsSettled(false)} + > + +
); } -const toContentPart = ( - item: ChildActivityItem, - reasoningMarkerLabel: string, -): TMessageContentParts => { +const toContentPart = (item: ChildActivityItem): TMessageContentParts => { if (item.type === 'writing') { return { type: ContentTypes.TEXT, @@ -188,14 +200,15 @@ const toContentPart = ( if (item.type === 'reasoning') { if (item.text == null || item.text === '') { return { - type: ContentTypes.ACTIVITY_LABEL, - [ContentTypes.ACTIVITY_LABEL]: item.label ?? reasoningMarkerLabel, - activity_label_type: 'phase', + type: ContentTypes.THINK, + think: '', + reasoning_unavailable: true, + ...(item.label == null ? {} : { reasoning_label: item.label }), } as TMessageContentParts; } return { type: ContentTypes.THINK, - think: item.text ?? '', + think: item.text, ...(item.label == null ? {} : { reasoning_label: item.label }), } as TMessageContentParts; } @@ -305,11 +318,7 @@ export function SubagentActivityContent({ }) { const localize = useLocalize(); const isSubmitting = activity.status === 'running' || activity.status === 'dispatched'; - const reasoningMarkerLabel = localize('com_ui_subagent_ticker_reasoning'); - const parts = useMemo( - () => activity.items.map((item) => toContentPart(item, reasoningMarkerLabel)), - [activity.items, reasoningMarkerLabel], - ); + const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]); const activityDetailsTruncated = hasTruncatedActivityDetails(activity); let body: React.ReactNode; diff --git a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx index c2a49c2bff..0049992b72 100644 --- a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx @@ -120,10 +120,10 @@ describe('SubagentConversation', () => { expect(container.querySelectorAll('.agent-turn')).toHaveLength(2); expect(container.querySelector('[data-subagent-conversation]')).toBeInTheDocument(); expect(screen.queryByText('com_ui_prompt')).not.toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_activity_details_truncated')).toBeInTheDocument(); expect( - screen.queryByText('com_ui_subagent_activity_details_truncated'), + screen.queryByText('com_ui_subagent_activity_details_unavailable'), ).not.toBeInTheDocument(); - expect(screen.getByText('com_ui_subagent_activity_details_unavailable')).toBeInTheDocument(); fireEvent.click( screen.getByRole('button', { diff --git a/client/src/components/Chat/Subagents/SubagentConversation.tsx b/client/src/components/Chat/Subagents/SubagentConversation.tsx index cc841d4c66..40638fa8d6 100644 --- a/client/src/components/Chat/Subagents/SubagentConversation.tsx +++ b/client/src/components/Chat/Subagents/SubagentConversation.tsx @@ -4,6 +4,7 @@ import { ContentTypes, EModelEndpoint } from 'librechat-data-provider'; import { Bot, ChevronDown, CornerDownRight, Radio } from 'lucide-react'; import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@librechat/client'; import type { TMessageContentParts } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; import type { ChildConversationTurn } from './adapters'; import type { TranslationKeys } from '~/hooks'; import { @@ -166,8 +167,27 @@ function ChildMessage({ const agentsMap = useAgentsMapContext(); const agent = agentId == null ? undefined : agentsMap?.[agentId]; const label = agent?.name ?? turn.activity.title; - const detailsLimited = - turn.activity.activityTruncated === true || hasTruncatedActivityDetails(turn.activity); + const wholeActivityTruncated = turn.activity.activityTruncated === true; + const detailsLimited = wholeActivityTruncated || hasTruncatedActivityDetails(turn.activity); + let limitedNotice: ReactNode; + if (wholeActivityTruncated && onLoadDetails != null && detailState !== 'unavailable') { + limitedNotice = ( + + ); + } else if (wholeActivityTruncated) { + limitedNotice = localize('com_ui_subagent_activity_details_unavailable'); + } else { + /** Only item-level fields were shortened for display; the run's full + * activity is otherwise present, so avoid the alarming "unavailable" + * framing there. */ + limitedNotice = ( + {localize('com_ui_subagent_activity_details_truncated')} + ); + } const iconData = { endpoint: EModelEndpoint.agents, modelLabel: label, @@ -204,19 +224,7 @@ function ChildMessage({ onCancelControl={onCancelControl} /> {detailsLimited && detailState !== 'loading' && ( -
- {turn.activity.activityTruncated === true && - onLoadDetails != null && - detailState !== 'unavailable' ? ( - - ) : ( - localize('com_ui_subagent_activity_details_unavailable') - )} -
+
{limitedNotice}
)} {detailState === 'loading' && (
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index ba4177aaf6..01d062328c 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -237,7 +237,15 @@ jest.mock('@librechat/client', () => { ); }, - Textarea: (props: React.ComponentProps<'textarea'>) =>