diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx index d9520e4e2f1..f86047be909 100644 --- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx @@ -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'; @@ -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 @@ -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 ? ( -
-
- ) : 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() !== '' ? : null; + const cursor = + showCursor && thoughtPeek == null ? ( +
+
+ ) : 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 @@ -827,6 +846,7 @@ export default function ActivityPhaseGroup({ return ( <> {group} + {thoughtPeek} {media} {cursor} diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index 8565ea28a0f..693037689f9 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -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 ''; } @@ -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(null); const fontSize = useAtomValue(fontSizeAtom); const peek = useMemo(() => lastSentences(text), [text]); @@ -88,6 +90,7 @@ const StreamingThoughtPeek = memo(({ text }: { text: string }) => {