Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { FailedRevealContext, FailedRevealPill, useFailedRevealTrigger } from '.
import { FOLD_RAIL_CLASSES, ROW_GLYPH_SLOT, TOOL_ROW_CLASSES } from './rows';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import useThrottledValue from '~/hooks/Messages/useThrottledValue';
import { AttachmentGroup, StreamingThoughtPeek } from './Parts';
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
import { getActivityLabelText } from '~/utils/activityLabels';
import { getOutcomeStatus, summarizeSpan } from './outcome';
Expand All @@ -31,7 +32,6 @@ import { StackedToolIcons } from './ToolOutput';
import { getSourceDomains } from './sources';
import { mapAttachments } from '~/utils/map';
import SearchVerticals from './verticals';
import { AttachmentGroup } from './Parts';
import { cn } from '~/utils';

/** Matches `EXPAND_TRANSITION` so the panel and the label ticker resolve on
Expand Down Expand Up @@ -682,13 +682,32 @@ export default function ActivityPhaseGroup({
* right for the streaming-markdown cursor, wrong here — so `after:!static`
* puts that one pseudo-element back in flow for the slot to center; the
* `!` is what outranks the dot rule's three-class selector. */
const cursor = showCursor ? (
<div className={TOOL_ROW_CLASSES} data-testid="activity-phase-cursor">
<span className={cn(ROW_GLYPH_SLOT, 'submitting')} aria-hidden="true">
<span className="result-thinking block after:!static" />
</span>
</div>
) : null;
/** The thought streaming at the tail of a collapsed live card, shown the
* way an unfolded thought shows it: the trailing sentences in a short
* fading window under the header (#14546). The fold had swallowed that
* peek with the rows, leaving one throttled sentence on the header to
* stand for a paragraph of live reasoning. It takes the cursor's place:
* moving text is its own sign the run is alive. */
const streamingThought = useMemo(() => {
if (!isLive || isExpanded || liveParts == null) {
return '';
}
const tail = liveParts[liveParts.length - 1];
if (tail?.type !== ContentTypes.THINK) {
return '';
}
return typeof tail.think === 'string' ? tail.think : (tail.think?.value ?? '');
}, [isLive, isExpanded, liveParts]);
const thoughtPeek =
streamingThought.trim() !== '' ? <StreamingThoughtPeek text={streamingThought} /> : null;
const cursor =
showCursor && thoughtPeek == null ? (
<div className={TOOL_ROW_CLASSES} data-testid="activity-phase-cursor">
<span className={cn(ROW_GLYPH_SLOT, 'submitting')} aria-hidden="true">
<span className="result-thinking block after:!static" />
</span>
</div>
) : null;
/** `AttachmentGroup` drops `web_search` attachments, and the nested segment
* renders with `hideAttachments` so its own `WebSearch` row stands down
* for this hoist — so without `SearchVerticals` here a phase containing a
Expand Down Expand Up @@ -827,6 +846,7 @@ export default function ActivityPhaseGroup({
return (
<>
{group}
{thoughtPeek}
{media}
{cursor}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ const PEEK_SENTENCES = 4;
* live peek. Bounds work on long reasoning by scanning only the trailing
* slice before splitting on sentence boundaries. */
const lastSentences = (text: string): string => {
const tail = text.trim().slice(-1200);
/** Stripped here as well as by the disclosures that own the text: the
* live card hands the peek a thought straight from the stream, tags on. */
const tail = stripThinkTags(text).slice(-1200);
if (!tail) {
return '';
}
Expand Down Expand Up @@ -65,7 +67,7 @@ const PEEK_FADE =
* out loud" treatment popularized by Grok). Decorative only (aria-hidden); the
* toggle button above it provides the accessible control.
*/
const StreamingThoughtPeek = memo(({ text }: { text: string }) => {
export const StreamingThoughtPeek = memo(({ text }: { text: string }) => {
const ref = useRef<HTMLDivElement>(null);
const fontSize = useAtomValue(fontSizeAtom);
const peek = useMemo(() => lastSentences(text), [text]);
Expand All @@ -88,6 +90,7 @@ const StreamingThoughtPeek = memo(({ text }: { text: string }) => {
<div
aria-hidden="true"
className="mt-1 overflow-hidden rounded-2xl border border-border-light px-4 py-3"
data-testid="streaming-thought-peek"
>
<div
ref={ref}
Expand Down
7 changes: 6 additions & 1 deletion client/src/components/Chat/Messages/Content/Parts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ export * from './OpenAIImageGen';

export { default as Text } from './Text';
export { default as CollapsibleText } from './CollapsibleText';
export { default as Reasoning, ReasoningMarker, ReasoningCompact } from './Reasoning';
export {
default as Reasoning,
ReasoningMarker,
ReasoningCompact,
StreamingThoughtPeek,
} from './Reasoning';
export { default as EmptyText } from './EmptyText';
export { default as LogContent } from './LogContent';
export { default as ExecuteCode } from './ExecuteCode';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -606,3 +606,60 @@ describe('ActivityPhaseGroup open live header', () => {
expect(within(screen.getByRole('button')).getByTitle('com_ui_thinking')).toBeInTheDocument();
});
});

describe('ActivityPhaseGroup streaming thought peek', () => {
const thought: TMessageContentParts = {
type: ContentTypes.THINK,
think: '<think>The refs share a commit. Next I check the ordering. Then the tags.',
} as unknown as TMessageContentParts;
const call: TMessageContentParts = {
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: {
id: 'c1',
name: 'lookup',
args: '{}',
type: 'tool_call',
output: '',
},
} as unknown as TMessageContentParts;

test("shows the streaming thought under a collapsed live card, in the cursor's place", () => {
render(
<ActivityPhaseGroup labelPart={makeLabelPart('')} hasContent liveParts={[thought]} showCursor>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
const peek = screen.getByTestId('streaming-thought-peek');
expect(peek).toHaveTextContent('Next I check the ordering. Then the tags.');
/** Straight from the stream, the thought still carries its opening tag. */
expect(peek).not.toHaveTextContent('<think>');
expect(screen.queryByTestId('activity-phase-cursor')).toBeNull();
/** Under the header, not inside the fold that would unmount it. */
expect(screen.getByTestId('activity-phase-panel')).not.toContainElement(peek);
});

test('gives way to the rows once the card is open', () => {
render(
<ActivityPhaseGroup labelPart={makeLabelPart('')} hasContent liveParts={[thought]}>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
fireEvent.click(screen.getByRole('button'));
expect(screen.queryByTestId('streaming-thought-peek')).toBeNull();
});

test('keeps the cursor when a call, not a thought, is at the tail', () => {
render(
<ActivityPhaseGroup
labelPart={makeLabelPart('')}
hasContent
liveParts={[thought, call]}
showCursor
>
<div data-testid="phase-content" />
</ActivityPhaseGroup>,
);
expect(screen.queryByTestId('streaming-thought-peek')).toBeNull();
expect(screen.getByTestId('activity-phase-cursor')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ jest.mock('@librechat/client', () => ({
}));

jest.mock('../Parts', () => ({
StreamingThoughtPeek: ({ text }: { text: string }) => (
<div data-testid="streaming-thought-peek">{text}</div>
),
AttachmentGroup: ({ attachments }: { attachments?: TAttachment[] }) => (
<div
data-testid="attachment-group"
Expand Down
14 changes: 14 additions & 0 deletions e2e/setup/fake-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const ASSERT_HISTORY_MARKER = 'E2E_ASSERT_HISTORY:';
const ASSERT_QUOTE_MARKER = 'E2E_ASSERT_QUOTE:';
const REPLY_MARKER = 'E2E_REPLY:';
const THINK_REPLY_MARKER = 'E2E_THINK_REPLY:';
const SLOW_THINK_REPLY_MARKER = 'E2E_SLOW_THINK_REPLY:';
const COUNTED_REPLY_MARKER = 'E2E_COUNTED_REPLY:';
const ORDERED_REPLY_MARKER = 'E2E_ORDERED_REPLY:';
const SLOW_REPLY_MARKER = 'E2E_SLOW_REPLY:';
Expand Down Expand Up @@ -618,6 +619,19 @@ function replyResponses(text) {
};
}

const slowThinkName = getMarkerValue(text, SLOW_THINK_REPLY_MARKER);
if (slowThinkName) {
/** Three sentences of reasoning, word by word at a readable pace, so a
* spec can watch the live header and the thought peek while it streams. */
return {
responses: [
'<think>First I read the request slowly. Then I gather the evidence with some care. ' +
`Finally I decide how to answer it.</think>\n\nE2E slow think reply done ${slowThinkName}`,
],
sleep: 120,
};
}

const thinkName = getMarkerValue(text, THINK_REPLY_MARKER);
if (thinkName) {
/** The `<think>` tags are parsed downstream by the agents stream pipeline, so this
Expand Down
84 changes: 84 additions & 0 deletions e2e/specs/mock/activity-thought.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { NEW_CHAT_PATH, messagesView, selectMockEndpoint, sendMessage } from './helpers';

const ENDPOINT = { label: 'Mock Provider F', model: 'mock-model-f' };
const uniqueLabel = () => `thought-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;

/** The line the live header currently shows: the current ticker line's title. */
async function headerLine(page: Page): Promise<string | null> {
const card = messagesView(page).getByTestId('activity-phase-card').first();
if (!(await card.isVisible().catch(() => false))) {
return null;
}
return card.locator('button [title]').first().getAttribute('title');
}

test.describe('live reasoning', () => {
test('previews finished sentences and the thought peek, then titles the open card', async ({
page,
}) => {
test.setTimeout(120000);
const label = uniqueLabel();
const finalText = `E2E slow think reply done ${label}`;
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectMockEndpoint(page, ENDPOINT);
const run = await sendMessage(page, `E2E_SLOW_THINK_REPLY:${label}`);
expect(run.ok()).toBeTruthy();

const lines = new Set<string>();
let peekSeen = false;
for (let index = 0; index < 20; index += 1) {
await page.waitForTimeout(150);
const line = await headerLine(page);
if (line != null) {
lines.add(line);
}
if (
await messagesView(page)
.getByTestId('streaming-thought-peek')
.isVisible()
.catch(() => false)
) {
peekSeen = true;
}
if (index === 6 && process.env.E2E_FOLD_SHOTS) {
await page.screenshot({ path: `${process.env.E2E_FOLD_SHOTS}/thought-collapsed.png` });
}
if (index === 12) {
/** Open the card mid-stream: the header becomes a title. */
await messagesView(page)
.getByTestId('activity-phase-card')
.getByRole('button')
.first()
.click();
await page.waitForTimeout(1100);
const open = await headerLine(page);
if (process.env.E2E_FOLD_SHOTS) {
await page.screenshot({ path: `${process.env.E2E_FOLD_SHOTS}/thought-open.png` });
}
expect(open, 'open card title').not.toMatch(/[a-z]\.$/);
expect(await messagesView(page).getByTestId('streaming-thought-peek').count()).toBe(0);
}
}
/** Every collapsed line was either the generic line or a whole sentence,
* never a fragment cut mid-thought. */
for (const line of lines) {
expect(line, `header line "${line}"`).toMatch(/^(Thinking\.\.\.|Thinking…|.+[.!?])$/);
}
expect(peekSeen, 'thought peek under the collapsed card').toBe(true);

await expect(messagesView(page).getByText(finalText)).toBeVisible({ timeout: 60000 });
/** The settled thought's header sits at the shared row scale. */
const thoughts = messagesView(page)
.getByRole('button', { name: /Thoughts|Thinking/ })
.first();
await expect(thoughts).toBeVisible();
const sizes = await thoughts.evaluate((button) => {
const root = getComputedStyle(document.documentElement);
const markdown = parseFloat(root.getPropertyValue('--markdown-font-size')) * 16;
return { button: parseFloat(getComputedStyle(button).fontSize), row: markdown * 0.9 };
});
expect(sizes.button).toBeCloseTo(sizes.row, 1);
});
});
Loading