fix: preserve tool run and answer state

This commit is contained in:
Marco Beretta 2026-08-02 03:13:27 +02:00
parent 3ff2780f44
commit 73b5162f39
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
12 changed files with 362 additions and 57 deletions

View file

@ -62,6 +62,14 @@ export default function AskUserQuestion({
/** Live pause: share the hook's checked set so the composer's Enter and
* this card submit exactly what the card displays. */
const checkedIndices = isLivePause ? answerMode.checked : localChecked;
const answerValue = isLivePause ? answerMode.answerText : answer;
const setAnswerValue = (value: string) => {
if (isLivePause) {
answerMode.setAnswerText(value);
return;
}
setAnswer(value);
};
const toggleIndex = (index: number) => {
if (isLivePause) {
answerMode.toggleChecked(index);
@ -72,7 +80,7 @@ export default function AskUserQuestion({
);
};
const trimmed = answer.trim();
const trimmed = answerValue.trim();
const canSubmit = multiSelect
? checkedIndices.length > 0 || trimmed.length > 0
: trimmed.length > 0;
@ -177,13 +185,13 @@ export default function AskUserQuestion({
)}
<TextareaAutosize
value={answer}
value={answerValue}
disabled={locked}
onChange={(e) => setAnswer(e.target.value)}
onChange={(e) => setAnswerValue(e.target.value)}
minRows={2}
maxRows={12}
placeholder={otherLabel ?? localize('com_ui_your_answer')}
className="w-full resize-none rounded-lg border border-border-light bg-surface-chat px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
className="w-full resize-none rounded-lg border border-border-light bg-surface-chat px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
aria-label={localize('com_ui_your_answer')}
/>

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ChevronRight, Users } from 'lucide-react';
import { EModelEndpoint } from 'librechat-data-provider';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import type { TAttachment, TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { SubagentRun } from '~/store/subagents';
import {
@ -60,6 +60,7 @@ export default function SubagentCall({
const runOverride: SubagentRun = {
toolCallId,
isSubmitting,
args,
output,
attachments,
@ -78,6 +79,7 @@ export default function SubagentCall({
if (!toolCallId) return;
const signature = [
initialProgress,
isSubmitting,
typeof args === 'string' ? args : JSON.stringify(args ?? null),
output ?? '',
persistedContent?.length ?? -1,
@ -91,23 +93,31 @@ export default function SubagentCall({
lastWrittenRef.current = signature;
setRuns((prev) => ({
...(prev ?? {}),
[toolCallId]: { toolCallId, args, output, attachments, persistedContent, initialProgress },
[toolCallId]: {
toolCallId,
isSubmitting,
args,
output,
attachments,
persistedContent,
initialProgress,
},
}));
}, [toolCallId, args, output, attachments, persistedContent, initialProgress, setRuns]);
}, [
toolCallId,
isSubmitting,
args,
output,
attachments,
persistedContent,
initialProgress,
setRuns,
]);
/** Auto-open the panel when a run first streams in mirrors
* `ToolArtifactCard`. `isSubmitting` is captured once at first render so a
* history mount (page load, back-navigation) never steals focus. */
const readInitialIsSubmitting = useRecoilCallback(
({ snapshot }) =>
() =>
snapshot.getLoadable(store.isSubmittingFamily(0)).valueMaybe() ?? false,
[],
);
const mountedDuringStreamRef = useRef<boolean | null>(null);
if (mountedDuringStreamRef.current === null) {
mountedDuringStreamRef.current = readInitialIsSubmitting();
}
const mountedDuringStreamRef = useRef(isSubmitting);
const autoFocusedRef = useRef(false);
useEffect(() => {
if (!toolCallId || autoFocusedRef.current || !mountedDuringStreamRef.current) return;

View file

@ -18,6 +18,8 @@ import { SUBAGENT_TICKER_THROTTLE_MS } from '../subagentShared';
import SubagentCall from '../SubagentCall';
import store from '~/store';
const mockMCPServerNames: string[] = [];
jest.mock('~/hooks', () => ({
useLocalize:
() =>
@ -66,8 +68,7 @@ jest.mock('~/components/Share/MessageIcon', () => ({
}));
jest.mock('~/hooks/MCP', () => {
const mcpServerNames: string[] = [];
return { useMCPServerNames: () => mcpServerNames };
return { useMCPServerNames: () => mockMCPServerNames };
});
jest.mock('~/utils', () => ({
@ -78,6 +79,7 @@ jest.mock('~/utils', () => ({
afterEach(() => {
jest.useRealTimers();
mockMCPServerNames.length = 0;
});
function foldEvents(events: SubagentUpdateEvent[]): {
@ -273,6 +275,42 @@ describe('SubagentCall — inline preview', () => {
expect(screen.getAllByText('Writing:')).toHaveLength(1);
});
it('preserves a configured MCP server boundary in the live ticker', () => {
mockMCPServerNames.push('Google_mcp_Workspace');
renderWithState({
toolCallId: 'call_mcp_ticker',
initialProgress: 0.3,
isSubmitting: true,
progress: progressFromEvents({
subagentRunId: 'run_a',
subagentType: 'self',
status: 'run_step',
events: [
{
runId: 'p',
subagentRunId: 'run_a',
subagentType: 'self',
phase: 'run_step',
data: {
stepDetails: {
type: 'tool_calls',
tool_calls: [
{
id: 'c1',
name: 'search_documents_mcp_Google_mcp_Workspace',
},
],
},
},
timestamp: '',
} as SubagentUpdateEvent,
],
}),
});
expect(screen.getByText('Google_mcp_Workspace')).toBeInTheDocument();
});
it('shows a one-line result summary from the final text once finished', () => {
renderWithState({
toolCallId: 'call_summary',
@ -412,4 +450,16 @@ describe('SubagentCall — panel open contract', () => {
});
expect(screen.getByTestId('current-run-id')).toHaveTextContent('');
});
it('does NOT auto-focus a historical run during an unrelated stream', () => {
renderWithState({
toolCallId: 'call_unrelated_stream',
initialProgress: 0.3,
isSubmitting: false,
initializeStreaming: true,
progress: null,
});
expect(screen.getByTestId('current-run-id')).toHaveTextContent('');
});
});

View file

@ -6,6 +6,7 @@ import type { SubagentUpdatePhase } from 'librechat-data-provider';
import type { SubagentTickerLine } from '~/utils/subagentContent';
import type { SubagentRun } from '~/store/subagents';
import { useAgentsMapContext } from '~/Providers';
import { useMCPServerNames } from '~/hooks/MCP';
import { parseToolName } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -150,11 +151,10 @@ export function useSubagentRunView(
const registered = useRecoilValue(store.subagentRunByIdSelector(toolCallId));
const run = runOverride ?? registered;
const progress = useRecoilValue(store.subagentProgressByToolCallId(toolCallId));
/** The inline card passes its own `isSubmitting` prop (the message's stream
* state); the panel, rendered outside the message tree, omits it and falls
* back to the conversation's global submit atom. */
const familyIsSubmitting = useRecoilValue(store.isSubmittingFamily(0));
const isSubmitting = isSubmittingOverride ?? familyIsSubmitting;
/** Submission state belongs to this run. The detached panel cannot use the
* conversation-wide atom: another response may be streaming while a
* historical stopped run is selected. */
const isSubmitting = isSubmittingOverride ?? run?.isSubmitting ?? false;
const agentsMap = useAgentsMapContext();
const initialProgress = run?.initialProgress ?? 0;
@ -263,11 +263,13 @@ function ToolNameBadge({ name }: { name: string }): JSX.Element {
function ToolIdentifier({
rawName,
localize,
mcpServerNames,
}: {
rawName: string;
localize: ReturnType<typeof useLocalize>;
mcpServerNames: readonly string[];
}): JSX.Element {
const parsed = parseToolName(rawName);
const parsed = parseToolName(rawName, mcpServerNames);
if (parsed.mcpServer) {
return (
<span className="inline-flex min-w-0 shrink items-baseline gap-1">
@ -291,6 +293,7 @@ function ToolIdentifier({
*/
export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Element {
const localize = useLocalize();
const mcpServerNames = useMCPServerNames();
if (line.kind === 'writing' || line.kind === 'reasoning') {
const prefix =
line.kind === 'writing'
@ -312,7 +315,7 @@ export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Elem
{line.toolNames.map((name, i) => (
<span key={`${i}-${name}`} className="flex min-w-0 items-baseline gap-1">
{i > 0 && <span className="shrink-0 text-text-tertiary">,</span>}
<ToolIdentifier rawName={name} localize={localize} />
<ToolIdentifier rawName={name} localize={localize} mcpServerNames={mcpServerNames} />
</span>
))}
{line.argsSnippet && (
@ -325,7 +328,11 @@ export function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Elem
if (line.kind === 'tool_complete') {
return (
<span className="flex w-full items-baseline gap-1 overflow-hidden whitespace-nowrap text-text-secondary">
<ToolIdentifier rawName={line.toolName} localize={localize} />
<ToolIdentifier
rawName={line.toolName}
localize={localize}
mcpServerNames={mcpServerNames}
/>
<span className="shrink-0 text-text-tertiary"></span>
<span className="min-w-0 flex-1 truncate">
{line.outputSnippet ?? localize('com_ui_subagent_ticker_tool_done')}

View file

@ -283,9 +283,9 @@ export default function ToolCallGroup({
/** For a single-tool group, lead with the tool's own (capitalized) label
* instead of the generic "Used 1 tool — name", which reads awkwardly. */
const singleToolLabel = useMemo(() => {
const raw = getToolDisplayLabel(toolMetadata[0]?.name ?? '', localize);
const raw = getToolDisplayLabel(toolMetadata[0]?.name ?? '', localize, mcpServerNames);
return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : '';
}, [toolMetadata, localize]);
}, [toolMetadata, localize, mcpServerNames]);
const autoExpand = useRecoilValue(store.autoExpandTools);
/** A labeled activity block is summarized by its header, so it collapses

View file

@ -0,0 +1,118 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import AskUserQuestion from '../AskUserQuestion';
const mockSubmitAnswer = jest.fn();
const mockSetAnswerText = jest.fn();
jest.mock('@librechat/client', () => ({
Button: ({
children,
size: _size,
variant: _variant,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { size?: string; variant?: string }) => (
<button type="button" {...props}>
{children}
</button>
),
TextareaAutosize: ({
minRows: _minRows,
maxRows: _maxRows,
...props
}: React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
minRows?: number;
maxRows?: number;
}) => <textarea {...props} />,
TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
}));
jest.mock('lucide-react', () => ({
ChevronUp: () => <span />,
MessageCircleQuestion: () => <span />,
TriangleAlert: () => <span />,
}));
jest.mock('../ApprovalContext', () => ({
useAskSubmitStatus: () => ({ getAskStatus: () => 'idle' }),
useResumeSubmit: () => ({ submitAskAnswer: jest.fn() }),
}));
jest.mock('~/hooks/Input/useAskAnswerMode', () => ({
__esModule: true,
default: () => ({
popoverVisible: false,
collapsed: true,
expand: jest.fn(),
liveAsk: { actionId: 'action-1' },
checked: [0],
toggleChecked: jest.fn(),
submitOption: jest.fn(),
submitAnswer: mockSubmitAnswer,
answerText: 'carried free-form answer',
setAnswerText: mockSetAnswerText,
}),
}));
jest.mock('~/components/Chat/ask/options', () => ({
__esModule: true,
default: () => <div data-testid="ask-options" />,
}));
jest.mock('~/Providers/ChatContext', () => {
const ReactModule = jest.requireActual<typeof React>('react');
return {
ChatContext: ReactModule.createContext({
conversation: { conversationId: 'conversation-1' },
}),
};
});
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
com_ui_your_answer: 'Your answer',
com_ui_skip: 'Skip',
com_ui_submit: 'Submit',
};
return translations[key] ?? key;
},
}));
jest.mock('~/utils', () => ({
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
}));
jest.mock('~/utils/approval', () => ({
ASK_USER_DECLINED_ANSWER: 'User declined to answer',
splitOtherOption: (options?: Array<{ label: string; value: string }>) => ({
choices: options ?? [],
otherLabel: undefined,
}),
}));
describe('AskUserQuestion', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('submits checked options together with free text carried from the composer', () => {
render(
<AskUserQuestion
actionId="action-1"
question={{
question: 'Choose a source',
multiSelect: true,
options: [{ label: 'Public data', value: 'public' }],
}}
/>,
);
expect(screen.getByRole('textbox', { name: 'Your answer' })).toHaveValue(
'carried free-form answer',
);
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(mockSubmitAnswer).toHaveBeenCalledWith(['public', 'carried free-form answer']);
});
});

View file

@ -7,6 +7,8 @@ import { scheduleMessageContentLayoutReconcile } from '~/hooks';
import ToolCallGroup from '../ToolCallGroup';
import { ToolAuthWarning } from '../auth';
const mockMCPServerNames: string[] = [];
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: Record<string | number, string>) => {
if (key === 'com_ui_ran_n_actions') {
@ -79,10 +81,9 @@ jest.mock('~/hooks', () => ({
}));
jest.mock('~/hooks/MCP', () => {
const mcpServerNames: string[] = [];
return {
useMCPIconMap: () => new Map(),
useMCPServerNames: () => mcpServerNames,
useMCPServerNames: () => mockMCPServerNames,
};
});
@ -111,7 +112,14 @@ jest.mock('~/utils/approval', () => ({
jest.mock('~/utils', () => ({
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
getToolDisplayLabel: (name: string) => {
getToolDisplayLabel: (name: string, _localize: unknown, knownServerNames?: readonly string[]) => {
const configuredServer = knownServerNames?.find((server) => name.endsWith(`_mcp_${server}`));
if (configuredServer) {
return configuredServer;
}
if (name.includes('_mcp_')) {
return name.slice(name.lastIndexOf('_mcp_') + '_mcp_'.length);
}
if (
['execute_code', 'bash_tool', 'run_tools_with_code', 'run_tools_with_bash'].includes(name)
) {
@ -254,6 +262,7 @@ describe('ToolCallGroup image hoisting', () => {
beforeEach(() => {
mockScheduleMessageContentLayoutReconcile.mockClear();
mockMCPServerNames.length = 0;
});
it('renders an AttachmentGroup outside the collapsible container with all attachments', () => {
@ -653,6 +662,22 @@ describe('ToolCallGroup image hoisting', () => {
);
});
it('preserves a configured MCP server boundary in a single-tool label', () => {
mockMCPServerNames.push('Google_mcp_Workspace');
renderGroup({
...baseProps,
parts: [
{
part: makePart('mcp-1', 'result', 'search_documents_mcp_Google_mcp_Workspace'),
idx: 0,
},
],
lastContentIdx: 0,
});
expect(screen.getByRole('button', { name: /^Google_mcp_Workspace$/ })).toBeInTheDocument();
});
it('summarizes repeated completed web searches as an outcome and count', () => {
const searchParts = Array.from({ length: 9 }, (_, idx) => ({
part: makePart(`w${idx}`, 'result', 'web_search'),

View file

@ -86,12 +86,13 @@ function RunIdProbe() {
return <div data-testid="run-id">{runId ?? ''}</div>;
}
function renderPanel(run: SubagentRun) {
function renderPanel(run: SubagentRun, conversationSubmitting = false) {
return render(
<RecoilRoot
initializeState={({ set }) => {
set(store.subagentRunsState, { [run.toolCallId]: run });
set(store.currentSubagentRunId, run.toolCallId);
set(store.isSubmittingFamily(0), conversationSubmitting);
}}
>
<RunIdProbe />
@ -159,4 +160,22 @@ describe('SubagentPanel', () => {
// self-spawn with no agent name → the localized "Agent" title
expect(screen.getByText('Agent')).toBeInTheDocument();
});
it('keeps a historical stopped run stopped during an unrelated stream', async () => {
renderPanel(
{
toolCallId: 'panel_stopped',
args: { subagent_type: 'self' },
output: '',
initialProgress: 0.4,
isSubmitting: false,
},
true,
);
await waitFor(() => {
expect(screen.getByText('Stopped')).toBeInTheDocument();
});
expect(screen.queryByText('Running')).not.toBeInTheDocument();
});
});

View file

@ -18,23 +18,25 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
jest.mock(
'@librechat/client',
() => {
const React = jest.requireActual<typeof import('react')>('react');
return {
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? React.createElement('div', null, children) : null,
OGDialogContent: ({ children }: { children: ReactNode }) =>
React.createElement('div', null, children),
Spinner: () => React.createElement('div', { 'data-testid': 'spinner' }),
useToastContext: () => ({
showToast: mockShowToast,
}),
};
},
{ virtual: true },
);
jest.mock('@librechat/client', () => {
const React = jest.requireActual<typeof import('react')>('react');
const ReactDOM = jest.requireActual<typeof import('react-dom')>('react-dom');
return {
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open
? ReactDOM.createPortal(
React.createElement('div', null, children),
globalThis.document.body,
)
: null,
OGDialogContent: ({ children }: { children: ReactNode }) =>
React.createElement('div', { role: 'dialog', 'data-state': 'open' }, children),
Spinner: () => React.createElement('div', { 'data-testid': 'spinner' }),
useToastContext: () => ({
showToast: mockShowToast,
}),
};
});
jest.mock('~/data-provider', () => ({
useGetFileConfig: ({ select }: { select?: (data: FileConfigInput | undefined) => unknown }) => ({
@ -71,7 +73,13 @@ jest.mock('~/utils', () => ({
}));
function getFileInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
/** Prefer the local render container used by the lightweight dialog mock,
* then the active portal. Exiting Headless UI portals can leave older file
* inputs in `document.body`; a document-wide first/last match is unstable. */
const selector = 'input[type="file"][accept=".zip,.skill,.md"]';
const input =
container.querySelector<HTMLInputElement>(selector) ??
document.querySelector<HTMLInputElement>(`[role="dialog"][data-state="open"] ${selector}`);
if (input == null) {
throw new Error('Upload input was not rendered');
}
@ -106,6 +114,18 @@ describe('UploadSkillDialog', () => {
expect(screen.getByText('File size must not exceed 1.06 MB')).toBeInTheDocument();
});
it('targets the current upload input when an exiting portal still has one', () => {
const staleInput = document.createElement('input');
staleInput.type = 'file';
staleInput.accept = '.zip,.skill,.md';
document.body.appendChild(staleInput);
const { container } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
expect(getFileInput(container)).not.toBe(staleInput);
staleInput.remove();
});
it('rejects files above the configured skill import limit before upload', () => {
const { container } = render(<UploadSkillDialog isOpen={true} setIsOpen={mockSetIsOpen} />);
const file = new File([new Uint8Array(1024 * 1024 + 1)], 'too-large.skill', {

View file

@ -3,6 +3,7 @@ const mockResetComposer = jest.fn();
const mockGetComposerText = jest.fn(() => 'answer from A');
const mockSetSelected = jest.fn();
const mockSetChecked = jest.fn();
const mockSetAnswerDraft = jest.fn();
let mockSaveDrafts = false;
jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: jest.fn() }));
@ -16,7 +17,10 @@ jest.mock('~/Providers', () => ({
getValues: mockGetComposerText,
}),
}));
jest.mock('~/utils', () => ({ getAskAnswerDraftId: (id: string) => `draft-${id}` }));
jest.mock('~/utils', () => ({
getAskAnswerDraftId: (id: string) => `draft-${id}`,
morphTransition: (update: () => void) => update(),
}));
jest.mock('recoil', () => ({
atom: (cfg: unknown) => cfg,
useRecoilState: (state: { key?: string }) => {
@ -26,13 +30,16 @@ jest.mock('recoil', () => ({
if (state.key === 'askAnswerModeChecked') {
return [[], mockSetChecked];
}
if (state.key === 'askAnswerModeText') {
return [{ actionId: null, text: '' }, mockSetAnswerDraft];
}
return [[], jest.fn()];
},
useRecoilValue: () => mockSaveDrafts,
}));
jest.mock('~/store', () => ({ __esModule: true, default: { saveDrafts: 'saveDrafts' } }));
import { renderHook } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react';
import { useGetMessagesByConvoId } from '~/data-provider';
import { findLiveAskUserQuestion } from '~/utils/approval';
import useAskAnswerMode from './useAskAnswerMode';
@ -96,6 +103,18 @@ describe('useAskAnswerMode', () => {
expect(result.current.liveAsk).toBeNull();
});
it('carries the composer answer into shared card state when collapsed', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
act(() => result.current.collapse());
expect(mockSetAnswerDraft).toHaveBeenCalledWith({
actionId: 'a1',
text: 'answer from A',
});
});
it('does not let a delayed answer success clear the composer or selection after navigation', () => {
let finishAnswer: (() => void) | undefined;
mockUseGetMessages.mockReturnValue({ data: liveAsk });

View file

@ -40,6 +40,12 @@ const askAnswerCheckedAtom = atom<number[]>({
default: [],
});
/** Free-form answer handed between the composer and the in-message card. */
const askAnswerTextAtom = atom<{ actionId: string | null; text: string }>({
key: 'askAnswerModeText',
default: { actionId: null, text: '' },
});
/**
* First-class "answer mode" for a live `ask_user_question` pause. Clicking an
* option submits it immediately (multi-select clicks toggle instead, confirmed
@ -73,6 +79,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
const [collapsedIds, setCollapsedIds] = useRecoilState(collapsedAskActionsAtom);
const [selected, setSelected] = useRecoilState(askAnswerSelectionAtom);
const [checked, setChecked] = useRecoilState(askAnswerCheckedAtom);
const [answerDraft, setAnswerDraft] = useRecoilState(askAnswerTextAtom);
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
const { submitAskAnswer } = useResumeSubmit();
/** Recoil-backed so the lock/status works from the composer, which renders
@ -143,6 +150,15 @@ export default function useAskAnswerMode(conversationId?: string | null) {
() => splitOtherOption(liveAsk?.question.options),
[liveAsk],
);
const answerText = answerDraft.actionId === liveAsk?.actionId ? answerDraft.text : '';
const setAnswerText = useCallback(
(text: string) => {
if (liveAsk) {
setAnswerDraft({ actionId: liveAsk.actionId, text });
}
},
[liveAsk, setAnswerDraft],
);
/** Selection state is per-question: a new pause must never inherit a stale
* highlight (or checks) whose Enter would submit the previous question's
@ -158,13 +174,15 @@ export default function useAskAnswerMode(conversationId?: string | null) {
* which morphTransition's synchronous flush requires. */
const collapse = useCallback(() => {
if (liveAsk) {
morphTransition(() =>
const composerAnswer = formContext?.getValues('text') ?? answerText;
morphTransition(() => {
setAnswerDraft({ actionId: liveAsk.actionId, text: composerAnswer });
setCollapsedIds((prev) =>
prev.includes(liveAsk.actionId) ? prev : [...prev, liveAsk.actionId],
),
);
);
});
}
}, [liveAsk, setCollapsedIds]);
}, [liveAsk, formContext, answerText, setAnswerDraft, setCollapsedIds]);
const expand = useCallback(() => {
if (liveAsk) {
@ -227,6 +245,11 @@ export default function useAskAnswerMode(conversationId?: string | null) {
}
setSelected(null);
setChecked([]);
setAnswerDraft((current) =>
current.actionId === submittedActionId
? { actionId: submittedActionId, text: '' }
: current,
);
if (
(consumedComposerText || (wasActive && saveDrafts)) &&
currentScope.formContext?.getValues('text') === submittedComposerText
@ -247,6 +270,7 @@ export default function useAskAnswerMode(conversationId?: string | null) {
submitAskAnswer,
setSelected,
setChecked,
setAnswerDraft,
],
);
@ -464,6 +488,8 @@ export default function useAskAnswerMode(conversationId?: string | null) {
setSelected,
checked,
toggleChecked,
answerText,
setAnswerText,
canSubmit,
submit,
submitOption,

View file

@ -62,6 +62,9 @@ export const subagentProgressByToolCallId = atomFamily<SubagentProgress | null,
*/
export interface SubagentRun {
toolCallId: string;
/** Whether this run's parent message is actively streaming. Kept with the
* run so detached consumers never infer status from an unrelated stream. */
isSubmitting?: boolean;
args?: string | Record<string, unknown>;
output?: string | null;
attachments?: TAttachment[];