🧩 feat: Collapsible Wake-Up Task Cards and Subagent UI Consistency (#15364)

* 🧩 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
This commit is contained in:
Danny Avila 2026-08-30 13:53:16 -04:00 committed by GitHub
parent cd3768ed1f
commit b0a559876f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1055 additions and 114 deletions

View file

@ -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({
<div
onClick={handleContainerClick}
className={cn(
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0',
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
isTemporary
? 'border-violet-800/60 bg-violet-950/10'
: 'border-border-light bg-surface-chat',
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl pb-4 sm:rounded-3xl sm:pb-0',
composerSurfaceClasses(),
isTextAreaFocused ? composerSurfaceShadow.focused : composerSurfaceShadow.blurred,
/* Temporary-chat accent is a ChatForm-only override, not part of
the shared composer-surface decision. */
isTemporary && 'border-violet-800/60 bg-violet-950/10',
)}
>
{project ? <ProjectLandingChip project={project} /> : null}

View file

@ -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 <ReasoningMarker label={part.reasoning_label} />;
}
return (
<Reasoning
reasoning={reasoning}

View file

@ -2,7 +2,7 @@ import { memo, useMemo, useState, useCallback, useRef, useId } from 'react';
import { useAtomValue } from 'jotai';
import { ContentTypes } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent } from 'react';
import { ThinkingContent, ThinkingButton, FloatingThinkingBar } from './Thinking';
import { ThinkingContent, ThinkingButton, ThinkingLabel, FloatingThinkingBar } from './Thinking';
import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import { showThinkingAtom } from '~/store/showThinking';
@ -37,6 +37,17 @@ type ReasoningProps = {
*
* For legacy text-based messages, see Thinking.tsx component.
*/
/** Reasoning that happened but whose text this view cannot show detached
* subagent projections keep only a marker. Renders the shared reasoning
* header row without a disclosure. */
export const ReasoningMarker = memo(({ label }: { label?: string }) => {
const localize = useLocalize();
const display = label?.trim() || localize('com_ui_thoughts');
return <ThinkingLabel label={display} title={localize('com_ui_thoughts_unavailable')} />;
});
ReasoningMarker.displayName = 'ReasoningMarker';
const Reasoning = memo((props: ReasoningProps) => {
const { reasoning, isLast, reasoningLabel } = props;
const contentId = useId();

View file

@ -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 (
<div className="mb-2 pb-2 pt-2">
<div
className={cn('flex w-full items-center justify-start leading-[18px]', fontSize)}
title={title}
>
<span className="relative mr-1.5 inline-flex h-[18px] w-[18px] items-center justify-center">
<Lightbulb className="icon-sm text-text-secondary" aria-hidden="true" />
</span>
<span className="min-w-0 truncate text-left text-text-secondary">{label}</span>
</div>
</div>
);
});
/**
* 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';

View file

@ -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();
});
});

View file

@ -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';

View file

@ -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<string, unknown> =>
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[] };
}

View file

@ -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<WakeupTask['status'], TranslationKeys>;
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<ActiveSubagentPanel | null>(() => {
/** 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 (
<div className="my-1.5 rounded-lg border border-border-light bg-surface-secondary/40 p-3">
<div className="flex min-h-6 items-center gap-1.5 text-xs text-text-secondary">
<StatusIcon
size={13}
aria-hidden
className={cn('shrink-0', status === 'failed' && 'text-status-error')}
/>
{title !== '' && <span className="min-w-0 truncate font-medium">{title}</span>}
<span className="shrink-0">{localize(subagentStatusLabelKey(status))}</span>
{selection != null && (
/** The trigger identity attributes let the panel's close handler
* return keyboard focus to this button. */
<Button
type="button"
variant="ghost"
size="sm"
onClick={openActivity}
data-subagent-tool-call={selection.toolCallId}
data-subagent-parent-message={selection.parentMessageId}
data-subagent-part-index={selection.partIndex}
className="ml-auto h-6 shrink-0 px-2 text-xs"
>
{localize('com_ui_wakeup_view_activity')}
</Button>
)}
</div>
{hasResult && (
<div className="markdown prose prose-sm message-content light dark:prose-invert mt-2 max-h-96 w-full max-w-none overflow-y-auto break-words pr-1 text-text-primary">
<MarkdownLite content={task.result} codeExecution={false} />
</div>
)}
</div>
);
}
/**
* 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<string>();
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 (
<div className="mb-2 mt-1 w-full">
<Button
variant="ghost"
type="button"
className="inline-flex h-auto w-full items-center justify-start gap-2 rounded-none bg-transparent p-0 py-1 text-text-secondary hover:bg-transparent hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy focus-visible:ring-offset-0"
onClick={handleToggle}
aria-expanded={isExpanded}
aria-label={headerLabel}
>
{display.kind === 'subagent' ? (
<div
className="flex h-5 w-5 shrink-0 items-center justify-center text-text-secondary"
aria-hidden="true"
>
<Users size={14} />
</div>
) : (
<StackedToolIcons toolNames={toolIconNames} mcpIconMap={mcpIconMap} maxIcons={4} />
)}
<span
className={cn(
'tool-status-text min-w-0 truncate font-medium',
anyFailed && 'text-text-warning',
)}
role="status"
title={headerLabel}
>
{headerLabel}
</span>
{nameSummary !== '' && (
<span className="min-w-0 max-w-[40%] truncate text-xs font-normal text-text-secondary">
· {nameSummary}
</span>
)}
<ChevronDown
className={cn(
'size-4 shrink-0 text-text-secondary transition-transform duration-200 ease-out',
isExpanded && 'rotate-180',
)}
aria-hidden="true"
/>
</Button>
<div
style={expandStyle}
onTransitionEnd={handleTransitionEnd}
aria-hidden={!isExpanded}
data-testid="wakeup-panel"
>
{shouldRenderBody && (
<div className="overflow-hidden" ref={expandRef}>
<div className="py-0.5 pl-4">
<div className="mt-1 text-xs text-text-secondary">
{localize('com_ui_wakeup_explainer')}
</div>
{display.tasks.map((task) => (
<WakeupTaskCard
key={task.taskId}
task={task}
kind={display.kind}
conversationId={conversationId}
/>
))}
</div>
</div>
)}
</div>
</div>
);
});
export default Wakeup;

View file

@ -10,6 +10,9 @@ jest.mock('../Parts', () => ({
AgentUpdate: () => <div data-testid="agent-update" />,
EmptyText: () => <div data-testid="empty-text" />,
Reasoning: () => <div data-testid="reasoning" />,
ReasoningMarker: ({ label }: { label?: string }) => (
<div data-testid="reasoning-marker">{label}</div>
),
Summary: () => <div data-testid="summary" />,
Text: ({ text }: { text?: string }) => <div data-testid="text">{text}</div>,
SkillCall: () => <div data-testid="skill-call" />,
@ -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();
});
});

View file

@ -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<string, string>) =>
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: () => <div data-testid="stacked-tool-icons" />,
}));
jest.mock('../MarkdownLite', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>,
}));
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'>) => (
<button {...props}>{children}</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 <div data-testid="selection">{selection == null ? '' : selection.durable?.taskId}</div>;
}
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(
<RecoilRoot>
<Wakeup display={subagentDisplay} conversationId="conversation-1" />
<SelectionProbe />
</RecoilRoot>,
);
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(
<RecoilRoot>
<Wakeup
display={{
kind: 'subagent',
tasks: [
{
taskId: 'task-9',
status: 'completed',
result: 'ok',
threadId: 'thread-9',
subagentType: 'self',
},
],
}}
conversationId="conversation-1"
/>
<SelectionProbe />
</RecoilRoot>,
);
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(
<RecoilRoot>
<Wakeup
display={{
kind: 'background_tool',
tasks: [
{
taskId: 'bg-1',
status: 'completed',
result: 'ok',
toolCallId: 'call-1',
toolName: 'web_search',
},
{
taskId: 'bg-2',
status: 'error',
result: 'boom',
toolCallId: 'call-2',
toolName: 'execute_code',
},
],
}}
conversationId="conversation-1"
/>
</RecoilRoot>,
);
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();
});
});

View file

@ -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={
<SubRow classes={cn(messageFooterClasses, msg.isCreatedByUser && 'justify-end')}>
{/* A user turn is right-aligned, so its retry navigation belongs at the
@ -207,20 +214,24 @@ const MessageRender = memo(function MessageRender({
}
>
<MessageContext.Provider value={messageContextValue}>
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text || ''}
message={msg}
enterEdit={enterEdit}
error={!!(msg.error ?? false)}
isSubmitting={isSubmitting}
unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
{wakeupDisplay != null && !edit ? (
<Wakeup display={wakeupDisplay} conversationId={conversation?.conversationId} />
) : (
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text || ''}
message={msg}
enterEdit={enterEdit}
error={!!(msg.error ?? false)}
isSubmitting={isSubmitting}
unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
)}
</MessageContext.Provider>
</MessageRow>
);

View file

@ -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 ? (
<h2 className="sr-only">
{headerPrefix}
@ -98,11 +104,11 @@ export default function MessageRow({
</h2>
))}
<div className={cn('flex w-full flex-col gap-1', isCreatedByUser && 'items-end')}>
<div className={cn('flex w-full flex-col gap-1', isCreatedByUser && !plain && 'items-end')}>
<div
className={cn(
'flex min-h-[20px] max-w-full flex-grow flex-col gap-0',
isCreatedByUser && !isEditing
isCreatedByUser && !isEditing && !plain
? 'w-fit rounded-theme-surface rounded-br-theme-control bg-surface-tertiary px-theme-normal py-2.5'
: 'w-full',
)}
@ -110,7 +116,9 @@ export default function MessageRow({
>
{children}
</div>
<div className={cn('w-full', isCreatedByUser && 'flex justify-end')}>{footer}</div>
<div className={cn('w-full', isCreatedByUser && !plain && 'flex justify-end')}>
{footer}
</div>
</div>
</div>
</div>

View file

@ -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(
<MessageRow
@ -34,12 +36,26 @@ const renderRow = ({
hasParallelContent={hasParallelContent}
fullWidth={fullWidth}
isEditing={isEditing}
plain={plain}
>
<p>{MESSAGE_BODY}</p>
</MessageRow>,
);
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 });

View file

@ -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 <div key={index}>{`reasoning-marker:${part.reasoning_label ?? ''}`}</div>;
}
return <div key={index}>{part.reasoning_label ?? part.think}</div>;
}
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(
<SubagentActivity
activity={{
...base,
status: 'running',
items: [{ type: 'reasoning', label: 'Planning the answer' }],
}}
/>,
);
expect(screen.getByText('reasoning-marker:Planning the answer')).toBeInTheDocument();
});
it('uses the regular thinking cursor without running-state prose', () => {

View file

@ -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<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const scrollButtonRef = useRef<HTMLDivElement>(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 (
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn('relative min-h-0 flex-1 overflow-y-auto', padded && 'px-4 py-4')}
data-subagent-activity-scroll-surface
>
{!isAtBottom && (
<Button
variant="ghost"
size="icon"
onClick={() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
});
setIsAtBottom(true);
}}
aria-label={localize('com_ui_subagent_scroll_to_bottom')}
className="sticky top-[calc(100%-2.75rem)] z-10 ml-auto h-8 w-8 rounded-full border border-border-light bg-surface-secondary text-text-secondary shadow-md"
>
<ArrowDown size={16} aria-hidden />
</Button>
)}
<div ref={contentRef}>{children}</div>
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn('min-h-0 flex-1 overflow-y-auto', padded && 'px-4 py-4')}
data-subagent-activity-scroll-surface
>
<div ref={contentRef}>{children}</div>
</div>
<CSSTransition
in={!isAtBottom && scrollButtonPreference}
timeout={{ enter: 300, exit: 180 }}
classNames="scroll-animation"
unmountOnExit={true}
appear={true}
nodeRef={scrollButtonRef}
onEntered={() => setIsSettled(true)}
onExit={() => setIsSettled(false)}
>
<ScrollToBottom
ref={scrollButtonRef}
scrollHandler={scrollToBottom}
interactive={isSettled}
/>
</CSSTransition>
</div>
);
}
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;

View file

@ -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', {

View file

@ -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 = (
<Button type="button" variant="ghost" size="sm" onClick={onLoadDetails}>
{detailState === 'error'
? localize('com_ui_retry')
: localize('com_ui_subagent_show_full_activity')}
</Button>
);
} 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 = (
<span className="italic">{localize('com_ui_subagent_activity_details_truncated')}</span>
);
}
const iconData = {
endpoint: EModelEndpoint.agents,
modelLabel: label,
@ -204,19 +224,7 @@ function ChildMessage({
onCancelControl={onCancelControl}
/>
{detailsLimited && detailState !== 'loading' && (
<div className="mt-2 text-xs text-text-secondary">
{turn.activity.activityTruncated === true &&
onLoadDetails != null &&
detailState !== 'unavailable' ? (
<Button type="button" variant="ghost" size="sm" onClick={onLoadDetails}>
{detailState === 'error'
? localize('com_ui_retry')
: localize('com_ui_subagent_show_full_activity')}
</Button>
) : (
localize('com_ui_subagent_activity_details_unavailable')
)}
</div>
<div className="mt-2 text-xs text-text-secondary">{limitedNotice}</div>
)}
{detailState === 'loading' && (
<div className="mt-2 text-xs text-text-secondary" aria-live="polite">

View file

@ -237,7 +237,15 @@ jest.mock('@librechat/client', () => {
</button>
);
},
Textarea: (props: React.ComponentProps<'textarea'>) => <textarea {...props} />,
composerSurfaceClasses: () => '',
composerSurfaceShadow: { focused: '', blurred: '', within: '' },
TextareaAutosize: ({
minRows: _minRows,
maxRows: _maxRows,
...props
}: React.ComponentProps<'textarea'> & { minRows?: number; maxRows?: number }) => (
<textarea {...props} />
),
useMediaQuery: () => mockIsMobile,
useToastContext: () => ({ showToast: mockShowToast }),
};

View file

@ -12,12 +12,14 @@ import {
import {
Button,
Alert,
composerSurfaceClasses,
composerSurfaceShadow,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
TextareaAutosize,
useMediaQuery,
useToastContext,
} from '@librechat/client';
@ -58,6 +60,7 @@ import { useParentSubagents } from './ParentSubagentsProvider';
import SubagentConversation from './SubagentConversation';
import { eventSubagentSelection } from './eventSelection';
import { useAgentsMapContext } from '~/Providers';
import { cn } from '~/utils';
const EVENT_TASK_PAGE_SIZE = 3;
const TERMINAL_CONTROL_REASONS = new Set([
@ -230,6 +233,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
useEffect(() => {
if (
selection.event == null ||
selection.event.pinnedTask === true ||
eventSummary?.latestTaskId == null ||
eventSummary.latestTaskId === taskId
) {
@ -1082,20 +1086,6 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
</h2>
)}
</div>
{canContinueAsChat && (
<Button
type="button"
variant="outline"
size="sm"
onClick={continueAsChat}
disabled={continueChat.isLoading}
aria-label={localize('com_ui_continue_chat')}
className="h-8 shrink-0 gap-1.5"
>
<MessagesSquare size={15} aria-hidden="true" />
<span className="hidden sm:inline">{localize('com_ui_continue_chat')}</span>
</Button>
)}
<Button
type="button"
variant="ghost"
@ -1116,8 +1106,8 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
>
{activityPanel}
</ApprovalProvider>
{showControlFooter && (
<div className="shrink-0 border-t border-border-light p-3">
{(showControlFooter || canContinueAsChat) && (
<div className="shrink-0 p-3 pt-2">
{transientControl?.status === 'failed' && (
<Alert variant="error" className="mb-2 flex items-center gap-2">
<span className="min-w-0 flex-1">
@ -1139,23 +1129,33 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
</Alert>
)}
{controlAvailable && (
<>
<Textarea
/* Same surface language as the main chat composer, scaled to the panel. */
<div
className={cn(
'flex w-full flex-col gap-1.5 rounded-3xl p-2.5',
composerSurfaceClasses(),
composerSurfaceShadow.within,
)}
>
<TextareaAutosize
value={controlMessage}
onChange={(event) => setControlMessage(event.target.value)}
placeholder={localize('com_ui_subagent_control_placeholder')}
aria-label={localize('com_ui_subagent_control_message')}
maxLength={4 * 1024}
rows={2}
minRows={1}
maxRows={6}
disabled={controlPending}
className="w-full resize-none bg-transparent px-1.5 py-1 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none disabled:cursor-not-allowed"
/>
<div className="mt-2 flex flex-wrap gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
disabled={controlPending || controlMessage.trim() === ''}
onClick={() => submitControl('steer')}
className="rounded-full"
>
<CornerDownRight size={14} aria-hidden />
{localize('com_ui_steer')}
@ -1166,6 +1166,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
variant="outline"
disabled={controlPending || controlMessage.trim() === ''}
onClick={() => submitControl('queue')}
className="rounded-full"
>
<ListEnd size={14} aria-hidden />
{localize('com_ui_queue')}
@ -1176,6 +1177,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
variant="outline"
disabled={controlPending || controlMessage.trim() === ''}
onClick={() => submitControl('interrupt')}
className="rounded-full"
>
<Zap size={14} aria-hidden />
{localize('com_ui_subagent_interrupt')}
@ -1186,13 +1188,28 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
variant="ghost"
disabled={controlPending}
onClick={() => submitControl('cancel')}
className="ml-auto text-status-error"
className="ml-auto rounded-full text-status-error"
>
<OctagonX size={14} aria-hidden />
{localize('com_ui_subagent_cancel_task')}
</Button>
</div>
</>
</div>
)}
{!controlAvailable && canContinueAsChat && (
/* The settled run keeps a footer affordance where the composer was,
instead of the input vanishing at completion. */
<Button
type="button"
variant="outline"
onClick={continueAsChat}
disabled={continueChat.isLoading}
aria-label={localize('com_ui_continue_chat')}
className="w-full gap-1.5 rounded-3xl"
>
<MessagesSquare size={15} aria-hidden="true" />
{localize('com_ui_continue_chat')}
</Button>
)}
</div>
)}

View file

@ -104,8 +104,9 @@ const contentPartsToActivity = (
{
type: 'reasoning',
...(reasoningVisibility === 'visible' ? { text: (part as { think: string }).think } : {}),
...(reasoningVisibility === 'visible' &&
typeof (part as { reasoning_label?: string }).reasoning_label === 'string'
/** The generated reasoning label is display-safe orientation, kept
* even when the reasoning text itself stays server-private. */
...(typeof (part as { reasoning_label?: string }).reasoning_label === 'string'
? { label: (part as { reasoning_label: string }).reasoning_label }
: {}),
},

View file

@ -8,9 +8,13 @@ export const eventSubagentSelection = (
parentConversationId: string,
child: ParentSubagentSummary,
siblingParentMessageIds?: string[],
requestedTaskId?: string,
): ActiveSubagentPanel | null => {
const taskId = child.latestTaskId;
const taskId = requestedTaskId ?? child.latestTaskId;
if (child.origin !== 'event' || child.actorId == null || taskId == null) return null;
/** Any explicitly requested task stays pinned even today's latest task can
* be displaced by a newer delivery while the panel is open. */
const pinnedTask = requestedTaskId != null;
return {
host: 'conversation',
parentConversationId,
@ -25,6 +29,32 @@ export const eventSubagentSelection = (
actorId: child.actorId,
progressKey: eventTaskProgressKey(child.threadId, taskId),
...(siblingParentMessageIds == null ? {} : { siblingParentMessageIds }),
...(pinnedTask ? { pinnedTask: true } : {}),
},
};
};
/** Panel selection for one durable task of an indexed child thread, regardless
* of how the child was dispatched. Event actors reuse their event selection so
* the panel keeps its actor picker and task timeline. */
export const durableSubagentSelection = (
parentConversationId: string,
child: ParentSubagentSummary,
taskId: string,
): ActiveSubagentPanel | null => {
if (child.origin === 'event') {
return eventSubagentSelection(parentConversationId, child, undefined, taskId);
}
const taskStatus = child.tasks.find((task) => task.taskId === taskId)?.status ?? child.status;
return {
host: 'conversation',
parentConversationId,
parentMessageId: child.parentMessageId,
toolCallId: child.parentToolCallId ?? `subagent-thread:${child.threadId}`,
partIndex: 0,
subagentType: child.subagentType,
initialProgress: taskStatus === 'running' ? 0 : 1,
isSubmitting: taskStatus === 'running',
durable: { threadId: child.threadId, taskId },
};
};

View file

@ -2272,7 +2272,6 @@
"com_ui_subagent_trigger_truncated": "Trigger summary shortened for display.",
"com_ui_subagent_turn": "Turn",
"com_ui_subagent_running": "Running agent",
"com_ui_subagent_scroll_to_bottom": "Scroll to latest",
"com_ui_subagent_ticker_error": "Error",
"com_ui_subagent_ticker_reasoning": "Reasoning",
"com_ui_subagent_ticker_tool_done": "done",
@ -2301,6 +2300,7 @@
"com_ui_text_variables": "Text variables",
"com_ui_thinking": "Thinking...",
"com_ui_thoughts": "Thoughts",
"com_ui_thoughts_unavailable": "Reasoning happened here, but background agents keep only this marker",
"com_ui_toggle_theme": "Toggle theme",
"com_ui_token": "token",
"com_ui_token_exchange_method": "Token Exchange Method",
@ -2423,6 +2423,14 @@
"com_ui_versions": "Versions",
"com_ui_via_server": "in {{0}}",
"com_ui_view_memory": "View Memory",
"com_ui_wakeup_explainer": "This durable result woke the agent to continue the conversation.",
"com_ui_wakeup_subagent_cancelled": "Subagent task cancelled",
"com_ui_wakeup_subagent_completed": "Subagent task completed",
"com_ui_wakeup_subagent_errored": "Subagent task failed",
"com_ui_wakeup_task_errored": "Background task failed",
"com_ui_wakeup_task_finished": "Background task finished",
"com_ui_wakeup_tasks_finished": "{{0}} background tasks finished",
"com_ui_wakeup_view_activity": "View activity",
"com_ui_wait_for_tool_steps": "Wait for tool steps instead",
"com_ui_web_search": "Web Search",
"com_ui_web_search_cohere_key": "Enter Cohere API Key",

View file

@ -276,6 +276,9 @@ export type ActiveSubagentPanel = {
progressKey: string;
/** Message anchors merged into the same parent-owned activity group. */
siblingParentMessageIds?: string[];
/** The selection deliberately targets a historical task; the panel must
* not snap it forward when the actor thread receives a newer delivery. */
pinnedTask?: boolean;
};
};

View file

@ -0,0 +1,19 @@
import { cn } from './utils';
/**
* Shared composer-surface appearance: every input surface that should read as
* "the composer" (main chat form, subagent control footer) draws its border,
* background, and text colors from this one semantic decision. Layout, radius,
* padding, and feature-specific overrides stay with each owner.
*/
export const composerSurfaceClasses = (): string =>
cn('border border-border-light bg-surface-chat text-text-primary transition-all duration-200');
/** Elevation states for the composer surface. `within` is the CSS-only
* equivalent of the managed focused/blurred pair for surfaces that do not
* track focus in state. */
export const composerSurfaceShadow = {
focused: 'shadow-lg',
blurred: 'shadow-md',
within: 'shadow-md focus-within:shadow-lg',
} as const;

View file

@ -1,4 +1,5 @@
export * from './utils';
export * from './theme';
export * from './composer';
export * from './cloudfront';
export { default as logger } from './logger';

View file

@ -726,6 +726,9 @@ export type TMessageContentParts =
reasoning_label_revision?: number;
/** Whether the reasoning step can still produce a newer label. */
reasoning_label_status?: 'streaming' | 'complete';
/** The reasoning happened but its text is not available to this view
* (e.g. detached subagent projections retain only a marker). */
reasoning_unavailable?: boolean;
} & ContentMetadata)
| (SteerContentPart & ContentMetadata)
| ({