💬 feat: Interim Progress Card for Streaming Q&A Calls (#14576)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* 💬 feat: Interim Progress Card for Streaming ask_user_question Calls

* 🔍 fix: Match Progress Card Against Every Live Ask Pause, Not Newest Only

*  feat: Hold Streaming Cursor Under Answered Question While Resume Is In Flight
This commit is contained in:
Danny Avila 2026-08-01 18:25:53 -04:00 committed by GitHub
parent 59395a6bf0
commit 2fb03118bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 370 additions and 43 deletions

View file

@ -1,6 +1,9 @@
import { MessageCircleQuestion, TriangleAlert } from 'lucide-react';
import { getSubmittedAskAnswer, parseAskUserQuestionArgs } from '~/utils/approval';
import AskUserQuestionProgress from './AskUserQuestionProgress';
import EmptyText from './Parts/EmptyText';
import { useLocalize } from '~/hooks';
import Container from './Container';
/**
* Static rendering of a COMPLETED (or abandoned) `ask_user_question` tool call
@ -15,12 +18,14 @@ export default function AskUserQuestionCall({
toolCallId,
isSubmitting = false,
failed = false,
showCursor = false,
}: {
args: string | Record<string, unknown> | undefined;
output: string;
toolCallId?: string;
isSubmitting?: boolean;
failed?: boolean;
showCursor?: boolean;
}) {
const localize = useLocalize();
const question = parseAskUserQuestionArgs(args);
@ -37,33 +42,51 @@ export default function AskUserQuestionCall({
* While the turn is live and unanswered, the INTERACTIVE card (rendered from
* the pendingAction's synthetic part) owns the question UI rendering the
* durable record too would duplicate it with a misleading "no answer" line.
* Once the user answers, the submit handler stamps `output` onto this part,
* so the record takes over immediately; an abandoned pause only shows its
* "no answer" state after the turn is no longer submitting.
* Until that pause actually starts (args still streaming, interrupt not yet
* delivered) the progress card fills the gap. Once the user answers, the
* submit handler stamps `output` onto this part, so the record takes over
* immediately; an abandoned pause only shows its "no answer" state after
* the turn is no longer submitting.
*/
if (!answered && !failed && isSubmitting) {
return null;
return <AskUserQuestionProgress args={args} toolCallId={toolCallId} />;
}
/**
* The run resumes the moment the pause resolves (an answer submits, or a
* schema-rejected call auto-retries), but its first token takes a beat to
* arrive hold the streaming cursor under the settled card so the turn
* never looks stalled between the answer and the resumed text.
*/
const resumingCursor =
isSubmitting && showCursor ? (
<Container>
<EmptyText />
</Container>
) : null;
if (failed) {
return (
<div
className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3"
role="status"
>
<div className="flex items-center gap-2 text-xs font-medium text-text-warning">
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_question_failed')}
</div>
{question?.question != null && (
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
{question.question}
<>
<div
className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3"
role="status"
>
<div className="flex items-center gap-2 text-xs font-medium text-text-warning">
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_question_failed')}
</div>
{question?.question != null && (
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
{question.question}
</p>
)}
<p className="text-sm text-text-secondary">
{localize('com_ui_question_failed_description')}
</p>
)}
<p className="text-sm text-text-secondary">
{localize('com_ui_question_failed_description')}
</p>
</div>
</div>
{resumingCursor}
</>
);
}
@ -88,29 +111,34 @@ export default function AskUserQuestionCall({
const answerLabel = exactLabel ?? mappedMultiLabel ?? effectiveOutput;
return (
<div className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3">
<div className="flex items-center gap-2 text-xs font-medium text-text-secondary">
<MessageCircleQuestion className="h-4 w-4" aria-hidden="true" />
{answered ? localize('com_ui_asked') : localize('com_ui_asking')}
<>
<div className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3">
<div className="flex items-center gap-2 text-xs font-medium text-text-secondary">
<MessageCircleQuestion className="h-4 w-4" aria-hidden="true" />
{answered ? localize('com_ui_asked') : localize('com_ui_asking')}
</div>
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
{question?.question ?? (answered ? localize('com_ui_asked') : localize('com_ui_asking'))}
</p>
{question?.description != null && question.description.length > 0 && (
<p className="text-sm text-text-secondary [overflow-wrap:anywhere]">
{question.description}
</p>
)}
{answered ? (
<p className="text-sm text-text-primary [overflow-wrap:anywhere]">
<span className="font-medium text-text-secondary">
{localize('com_ui_you_answered')}
</span>{' '}
{answerLabel}
</p>
) : (
<p className="text-sm italic text-text-secondary">
{localize('com_ui_question_unanswered')}
</p>
)}
</div>
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">
{question?.question ?? (answered ? localize('com_ui_asked') : localize('com_ui_asking'))}
</p>
{question?.description != null && question.description.length > 0 && (
<p className="text-sm text-text-secondary [overflow-wrap:anywhere]">
{question.description}
</p>
)}
{answered ? (
<p className="text-sm text-text-primary [overflow-wrap:anywhere]">
<span className="font-medium text-text-secondary">{localize('com_ui_you_answered')}</span>{' '}
{answerLabel}
</p>
) : (
<p className="text-sm italic text-text-secondary">
{localize('com_ui_question_unanswered')}
</p>
)}
</div>
{resumingCursor}
</>
);
}

View file

@ -0,0 +1,71 @@
import { useContext } from 'react';
import { MessageCircleQuestion } from 'lucide-react';
import { collectLiveAskToolCallIds } from '~/utils/approval';
import { useGetMessagesByConvoId } from '~/data-provider';
import { ChatContext } from '~/Providers/ChatContext';
import parseJsonField from './Parts/parseJsonField';
import { useLocalize } from '~/hooks';
/**
* Interim card for an `ask_user_question` call whose pause hasn't gone
* interactive yet: the window between the model starting the call and the
* interrupt's synthetic part arriving (which hands the UI to the popover /
* interactive card). Without it a long question, many options, or several
* parallel questions leave a dead gap after the last streamed token.
*
* The question text streams in live: `question` is the schema's first (and
* only required) property, so providers emit it as the first args key and
* `parseJsonField`'s partial-JSON path can render it delta by delta.
*
* Mounted only for a live, unanswered call ({@link AskUserQuestionCall}
* gates on `isSubmitting`), so the live-ask subscription below never runs
* for the settled cards in history.
*/
export default function AskUserQuestionProgress({
args,
toolCallId,
}: {
args: string | Record<string, unknown> | undefined;
toolCallId?: string;
}) {
const localize = useLocalize();
const conversationId = useContext(ChatContext)?.conversation?.conversationId;
const enabled = conversationId != null && conversationId !== 'new';
const { data: livePauses } = useGetMessagesByConvoId(enabled ? conversationId : '', {
enabled,
select: collectLiveAskToolCallIds,
});
const question = parseJsonField(args, 'question');
/**
* THIS call's pause went interactive: the popover (or the interactive card)
* now owns the question's UI. Checked against every live pause, not just the
* newest, so a sibling pause arriving later can never resurrect this card
* next to its own interactive one. An unattributed live ask (no
* `tool_call_id` on older payloads) can't be matched to a call, so treat it
* as owning every placeholder rather than duplicating the question on screen.
*/
const interactive =
livePauses != null &&
(livePauses.hasUnattributed || (toolCallId != null && livePauses.ids.includes(toolCallId)));
if (interactive) {
return null;
}
return (
<div className="my-2 flex w-full flex-col gap-1.5 rounded-lg border border-border-light bg-surface-secondary p-3">
<div
className="flex items-center gap-2 text-xs font-medium text-text-secondary"
role="status"
>
<MessageCircleQuestion className="h-4 w-4 animate-pulse" aria-hidden="true" />
<span className="shimmer">{localize('com_ui_asking')}</span>
</div>
{question.length > 0 ? (
<p className="text-sm font-medium text-text-primary [overflow-wrap:anywhere]">{question}</p>
) : (
<div className="h-4 w-2/5 animate-pulse rounded bg-surface-tertiary" aria-hidden="true" />
)}
</div>
);
}

View file

@ -238,6 +238,7 @@ const Part = memo(function Part({
output={typeof toolCall.output === 'string' ? toolCall.output : ''}
toolCallId={toolCall.id}
isSubmitting={isSubmitting}
showCursor={showCursor}
failed={'inputValidationError' in toolCall && toolCall.inputValidationError === true}
/>
);

View file

@ -26,12 +26,35 @@ jest.mock('~/utils/approval', () => ({
},
}));
jest.mock('../AskUserQuestionProgress', () => ({
__esModule: true,
default: () => {
const { createElement } = jest.requireActual<typeof React>('react');
return createElement('div', { 'data-testid': 'ask-progress' });
},
}));
jest.mock('../Container', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => {
const { createElement } = jest.requireActual<typeof React>('react');
return createElement('div', null, children);
},
}));
describe('AskUserQuestionCall', () => {
const args = JSON.stringify({
question: 'How would you like me to get the data?',
options: [{ label: 'Use public data', value: 'public' }],
});
test('renders the progress card while the call is live and unanswered', () => {
render(<AskUserQuestionCall args={args} output="" toolCallId="call_1" isSubmitting />);
expect(screen.getByTestId('ask-progress')).toBeInTheDocument();
expect(screen.queryByText('You answered:')).not.toBeInTheDocument();
});
test('renders a successful tool result as the user answer', () => {
render(<AskUserQuestionCall args={args} output="public" />);
@ -39,6 +62,31 @@ describe('AskUserQuestionCall', () => {
expect(screen.getByText('Use public data')).toBeInTheDocument();
});
test('holds the streaming cursor under the answered card while the resume is in flight', () => {
const { container } = render(
<AskUserQuestionCall
args={args}
output="public"
toolCallId="call_1"
isSubmitting
showCursor
/>,
);
expect(screen.getByText('You answered:')).toBeInTheDocument();
expect(container.querySelector('.result-thinking')).not.toBeNull();
});
test('shows no cursor once the record is not the streaming tail', () => {
const settled = render(<AskUserQuestionCall args={args} output="public" showCursor />);
expect(settled.container.querySelector('.result-thinking')).toBeNull();
const midStream = render(
<AskUserQuestionCall args={args} output="public" toolCallId="call_1" isSubmitting />,
);
expect(midStream.container.querySelector('.result-thinking')).toBeNull();
});
test('renders schema rejection as an internal question failure, not a user answer', () => {
const output =
'Error processing tool: Received tool input did not match expected schema ' +

View file

@ -0,0 +1,100 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import AskUserQuestionProgress from '../AskUserQuestionProgress';
const translations: Record<string, string> = {
com_ui_asking: 'Asking',
};
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => translations[key] ?? key,
}));
jest.mock('~/Providers/ChatContext', () => {
const { createContext } = jest.requireActual<typeof React>('react');
return {
ChatContext: createContext({ conversation: { conversationId: 'convo-1' } }),
};
});
let mockLivePauses: { ids: string[]; hasUnattributed: boolean } = {
ids: [],
hasUnattributed: false,
};
jest.mock('~/data-provider', () => ({
useGetMessagesByConvoId: () => ({ data: mockLivePauses }),
}));
describe('AskUserQuestionProgress', () => {
beforeEach(() => {
mockLivePauses = { ids: [], hasUnattributed: false };
});
test('streams the question text from partial args', () => {
render(
<AskUserQuestionProgress
args={'{"question":"Which environment should I dep'}
toolCallId="call_1"
/>,
);
expect(screen.getByText('Asking')).toBeInTheDocument();
expect(screen.getByText('Which environment should I dep')).toBeInTheDocument();
});
test('decodes JSON escapes in the streaming question', () => {
render(
<AskUserQuestionProgress args={'{"question":"Caf\\u00e9 or \\"bar\\"'} toolCallId="call_1" />,
);
expect(screen.getByText('Café or "bar"')).toBeInTheDocument();
});
test('renders a skeleton line before any question text streams', () => {
render(<AskUserQuestionProgress args="" toolCallId="call_1" />);
expect(screen.getByText('Asking')).toBeInTheDocument();
expect(screen.getByRole('status')).toBeInTheDocument();
});
test('hides once the interactive pause for this call is live', () => {
mockLivePauses = { ids: ['call_1'], hasUnattributed: false };
const { container } = render(
<AskUserQuestionProgress args={'{"question":"Ready?"}'} toolCallId="call_1" />,
);
expect(container).toBeEmptyDOMElement();
});
test('hides for an unattributed live pause (no tool_call_id on the payload)', () => {
mockLivePauses = { ids: [], hasUnattributed: true };
const { container } = render(
<AskUserQuestionProgress args={'{"question":"Ready?"}'} toolCallId="call_1" />,
);
expect(container).toBeEmptyDOMElement();
});
test("stays visible while a DIFFERENT call's pause is interactive", () => {
mockLivePauses = { ids: ['call_1'], hasUnattributed: false };
render(
<AskUserQuestionProgress args={'{"question":"Second question?"}'} toolCallId="call_2" />,
);
expect(screen.getByText('Second question?')).toBeInTheDocument();
});
test('hides when its own pause is live alongside a newer sibling pause', () => {
mockLivePauses = { ids: ['call_1', 'call_2'], hasUnattributed: false };
const { container } = render(
<AskUserQuestionProgress args={'{"question":"First question?"}'} toolCallId="call_1" />,
);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -11,6 +11,7 @@ import {
resolveAskUserQuestionPart,
getSubmittedAskAnswer,
findLiveAskUserQuestion,
collectLiveAskToolCallIds,
isAnsweredAskUserQuestionPart,
splitOtherOption,
} from './approval';
@ -477,6 +478,47 @@ describe('findLiveAskUserQuestion', () => {
});
});
describe('collectLiveAskToolCallIds', () => {
const attributedAsk = (actionId: string, toolCallId: string) =>
askAction({
actionId,
payload: {
type: 'ask_user_question',
question: { question: 'Q?' },
tool_call_id: toolCallId,
},
});
it('collects every live pause id, not just the newest, and drops answered ones', () => {
const first = applyPendingAction(msg({ content: [] }), attributedAsk('a-first', 'call_1'));
const both = applyPendingAction(first, attributedAsk('a-second', 'call_2'));
expect(collectLiveAskToolCallIds([both])).toEqual({
ids: ['call_1', 'call_2'],
hasUnattributed: false,
});
resolveAskUserQuestionPart(both, 'a-first', 'Ada');
// `both` is the pre-answer copy — the answered pause must still drop out.
expect(collectLiveAskToolCallIds([both])).toEqual({
ids: ['call_2'],
hasUnattributed: false,
});
});
it('flags unattributed pauses and handles non-array input', () => {
const unattributed = applyPendingAction(
msg({ content: [] }),
askAction({ actionId: 'a-unattributed' }),
);
expect(collectLiveAskToolCallIds([unattributed])).toEqual({ ids: [], hasUnattributed: true });
expect(collectLiveAskToolCallIds(null)).toEqual({ ids: [], hasUnattributed: false });
expect(collectLiveAskToolCallIds(undefined)).toEqual({ ids: [], hasUnattributed: false });
});
});
describe('isAnsweredAskUserQuestionPart', () => {
it('marks only cards whose question was actually answered', () => {
const live = applyPendingAction(msg({ content: [] }), askAction({ actionId: 'a-open' }));

View file

@ -447,6 +447,43 @@ export function findLiveAskUserQuestion(
return null;
}
/**
* EVERY live (unanswered) ask pause across the conversation, as the set of
* tool_call_ids their synthetic parts attribute, plus whether any live part
* lacks attribution (older payloads). Unlike {@link findLiveAskUserQuestion}
* (newest-only, the popover's signal), this lets a per-call surface the
* streaming progress card test whether ITS OWN pause is live even when a
* newer sibling pause exists.
*/
export function collectLiveAskToolCallIds(messages: TMessage[] | null | undefined): {
ids: string[];
hasUnattributed: boolean;
} {
const ids: string[] = [];
let hasUnattributed = false;
if (!Array.isArray(messages)) {
return { ids, hasUnattributed };
}
for (const message of messages) {
const content = message?.content;
if (!Array.isArray(content)) {
continue;
}
for (const part of content) {
if (!isAskUserQuestionPart(part) || isAnsweredAskUserQuestionPart(part)) {
continue;
}
const toolCallId = (part as unknown as AskUserQuestionPart)[ASK_USER_QUESTION].tool_call_id;
if (toolCallId == null) {
hasUnattributed = true;
} else {
ids.push(toolCallId);
}
}
}
return { ids, hasUnattributed };
}
/**
* Applies a {@link Agents.PendingAction} onto the target response message,
* dispatching on the interrupt type. Pure returns a new message only when the