perf(frontend/copilot): render transcript rows through a memoised MessageRow (#15715)

Resolves SECRT-3005 (wave 2 of SECRT-2993).

### Why / What / How

**Why:** `ChatMessagesContainer` rendered every transcript row inline
inside `renderRows.map` with no memo boundary. On every streamed flush
(~33/s) all rows re-rendered and re-derived their parts, special
markers, copy text and artifact cards, and `turnMessagesForRow` did a
`findIndex` per row (O(n²)). In the 200-message perf chat this costs
~2,000 renders per commit, and on `dev` the page falls so far behind the
stream that the same 20-line reply takes **48 s** to finish drawing
instead of 7 s.

**What:** transcript rows now render through a memoised `MessageRow`, so
while a reply streams only the streaming row renders. Markup, classes
and keys are unchanged (the rendered DOM of all 200 rows is
byte-identical before and after, see Evidence).

**How:**
- New `components/MessageRow/` (`MessageRow.tsx` + `useMessageRow.ts` +
`helpers.ts`): the per-row markup moved over verbatim, wrapped in
`React.memo`. Everything derived from the message (renderable parts,
text/file/folder parts, error marker, copy text, run-post preview, live
compaction phase) is memoised on `message.parts`/`metadata`.
- Props are row-specific so they keep their identity: `isLastAssistant`,
`isCurrentlyStreaming`, `isLastInTurn`, `turnStat` (this row's entry,
not the map), `turnMessages` (only when the stats bar shows),
`elapsedSeconds` / `indicator` / `onRetry` (last row only),
`isSessionQueued` (user rows only), `sessionSentFrom` (opening row
only). The comparator is identity for everything except `turnMessages`
(element-wise) and `turnStat` / `sessionSentFrom` (shallow), which the
parent rebuilds every pass. The props are documented in the file for
SECRT-3011/3012.
- `turnMessagesForRow` takes an id→index `Map` built once per `messages`
(`buildMessageIndexById`).
- `hideKickoffMessages` returns its input when there is no kickoff
message; `splitMessagesAtDrainHints` already returned its input when
nothing splits, and now caches a split message's segments per message
object (`WeakMap`) so settled split rows keep their identity too.
- `ChainMessageParts` memoises `buildChainSegments(parts)`, so
`ToolChain`'s existing `rows` `useMemo` gets the same `parts` back and
hits (no change in `ToolChain.tsx` was needed).
- `TextWithArtifactCards` memoises `extractWorkspaceArtifacts` /
`resolveWorkspaceUrls` on `(text, fileUrlBuilder)`.

### Changes 🏗️

- `ChatMessagesContainer.tsx`: row loop now renders `<MessageRow
key={message.id} …>`; held-call rows stay inline; `renderRows` / index
map memoised on `messages`. The thinking-timer code from SECRT-2995 and
the pending-answer contexts from SECRT-2996 are untouched.
- New
`ChatMessagesContainer/components/MessageRow/{MessageRow.tsx,useMessageRow.ts,helpers.ts}`,
with the row actions in
`MessageRow/components/{UserRowActions,AssistantRowActions}.tsx` (split
out after review, DOM re-checked identical).
- `midTurnSplit.ts`: `turnMessagesForRow(messages, row, indexById)`,
`buildMessageIndexById`, segment cache.
- `expertKickoff.ts`: `hideKickoffMessages` identity fast path.
- `ChainMessageParts.tsx`, `MessagePartRenderer.tsx`: the two memos
above.
- Tests: new `__tests__/messageRowMemo.test.tsx` (50-message
transcript); additions to `midTurnSplit.test.ts`,
`expertKickoff.test.ts`, `ChainMessageParts.test.tsx`,
`MessagePartRenderer.test.tsx`. Every new test fails on `dev` and passes
here.

No shared files (`scenarios/registry.ts`, `lib/routes.ts`,
`frontend-perf-baseline.md`, stores, providers, `useCopilotStream.ts`,
`useCopilotPage.ts`, `copilot/helpers.ts`, `copilot/store.ts`) are
touched.

### Evidence

All measured on this sandbox (`NEXT_PUBLIC_RENDER_COUNTER=1 pnpm dev`,
React development build), `dev` at `ede2df2ae` vs this branch,
alternating before → after → before → after (8 runs each side).

#### `pnpm perf:compare --a before --b after --scenario
copilot-stream-reply`

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 116 | 479 | +363 (+312.5%) |
| total renders | 215,550 | 72,915 | -142,636 (-66.2%) |
| renders per commit | 1,996.5 | 152.3 | -1,844.2 (-92.4%) |
| max renders in one commit | 9,299 | 9,561 | +262 (+2.8%) |
| script time (ms) | 10,080 | 7,354 | -2,726 (-27.0%) |
| task time (ms) | 10,895 | 8,670 | -2,225 (-20.4%) |
| layout time (ms) | 44 | 141 | +96 (+216.7%) |
| style recalc time (ms) | 168 | 300 | +131 (+78.0%) |
| long tasks (≥ 50 ms) | 24 | 36 | +12 (+50.0%) |
| long task total (ms) | 10,327 | 4,761 | -5,566 (-53.9%) |
| heap used after GC (MB) | 97.1 | 88.6 | -8.5 (-8.8%) |

| component (median renders) | before | after |
| --- | --- | --- |
| `Message` (one per transcript row render) | 3,910 | 628 |
| `MessagePartRenderer` | 3,908 | 626 |
| `ChainMessageParts` | 1,954 | 373 |
| `TurnStatsBar` | 1,932 | 261 |
| `Tooltip` | 15,816 | 3,649 |

**How to read it honestly:** the two windows do not contain the same
amount of work. On `dev` the page cannot keep up with the stream: every
before run (8/8, plus warm-ups) ends with the reply still half drawn and
logs `⚠ the page did not refetch the session after turn 1`, and each of
its ~116 commits batches many flushes at ~2,000 renders. With this PR
every run (8/8) draws the whole reply, refetches the session and shows
the actions inside the window, with one commit per flush at ~150
renders. So commits, layout, style recalc and long-task *count* go up
because the after window does more (the whole reply, one commit per
flush), while total renders (-66%), script time (-27%) and long-task
time (-54%) go down. Render counts are very stable on the after side
(72,596 – 73,089).

The remaining ~336 `MessageRow` renders at the end of the turn are one
full-transcript re-render with `props:turnStat` /
`props:turnStat,turnMessages`: `useCopilotPage` swaps
`runtimeStream.turnStats` for `historicalTurnStats` and the session
refetch rebuilds the message objects, so those props really change. That
belongs to the message-pipeline work (see follow-ups).

<details><summary>Raw runs</summary>

| label | run | commits | renders | renders/commit | script ms | long
tasks | heap MB |
| --- | --- | --- | --- | --- | --- | --- | --- |
| before | 1 | 85 | 140,836 | 1,656.9 | 10,344 | 16 | 90.7 |
| before | 2 | 120 | 215,550 | 1,796.3 | 9,991 | 24 | 99.7 |
| before | 3 | 95 | 196,591 | 2,069.4 | 10,348 | 20 | 96.3 |
| before | 4 | 119 | 215,550 | 1,811.3 | 10,170 | 25 | 95.6 |
| before | 5 | 108 | 215,280 | 1,993.3 | 9,811 | 22 | 98.2 |
| before | 1 | 120 | 243,277 | 2,027.3 | 10,204 | 25 | 98.1 |
| before | 2 | 117 | 233,952 | 1,999.6 | 9,778 | 24 | 96.0 |
| before | 3 | 115 | 233,943 | 2,034.3 | 9,834 | 24 | 97.8 |
| after | 1 | 478 | 72,969 | 152.7 | 8,404 | 42 | 88.9 |
| after | 2 | 472 | 72,915 | 154.5 | 7,609 | 36 | 89.0 |
| after | 3 | 469 | 73,020 | 155.7 | 7,787 | 44 | 90.4 |
| after | 4 | 481 | 72,763 | 151.3 | 6,690 | 35 | 89.3 |
| after | 5 | 481 | 73,089 | 152.0 | 6,863 | 35 | 87.7 |
| after | 1 | 480 | 72,596 | 151.2 | 6,843 | 36 | 88.3 |
| after | 2 | 473 | 72,914 | 154.2 | 7,756 | 42 | 88.2 |
| after | 3 | 479 | 72,620 | 151.6 | 7,100 | 36 | 87.0 |

</details>

#### `pnpm perf:compare --a before --b after --scenario copilot-type`

| metric | before | after | Δ |
| --- | --- | --- | --- |
| commits | 40 | 40 | 0 (0.0%) |
| total renders | 2,057 | 2,057 | 0 (0.0%) |
| renders per commit | 51.4 | 51.4 | 0.0 (0.0%) |
| max renders in one commit | 214 | 214 | 0 (0.0%) |
| script time (ms) | 390 | 340 | -50 (-12.9%) |

Typing already rendered 0 transcript rows on `dev` (SECRT-2996 fixed
that); it still does. The timing difference is within the before spread
(17%), so it is noise.

#### Screenshots (real app on :3000, `--screenshot` at the end of the
perf window)

Before (`dev`): the window ends with the reply still streaming in ("Keep
the…", Stop button).

![before: reply still drawing at end of
window](https://github.com/user-attachments/assets/a59b316a-d1e3-43d9-bba6-cd9d1bc8ab73)

After: the whole reply is drawn, the turn has ended and the actions
show.

![after: reply complete at end of
window](https://github.com/user-attachments/assets/42183377-9ac4-4ecc-8b28-fe67b1574ac5)

#### Negative check: same DOM, same behaviour

A one-off Playwright script (not committed) opened the 200-message
fixture chat on `dev` files and on this branch, dumped the `outerHTML`
of every `[data-message-id]` row (React/Radix ids blanked), sent "go",
waited for the full reply, and dumped again:

- At rest: **byte-identical** (1,527,559 bytes, all 200 rows).
- After the streamed turn: identical except one string in the new row's
stats bar, **"Thought for 48s" (dev) vs "Thought for 7s" (this PR)**,
i.e. how long the page took to finish drawing the same reply.

Before (`dev`), reply finished: "Thought for 48s"

![before: Thought for
48s](https://github.com/user-attachments/assets/4f8c38b7-5db3-4a3e-b64e-c30dcb540d4e)

After: "Thought for 7s", otherwise identical

![after: Thought for
7s](https://github.com/user-attachments/assets/8fa9b624-eef6-4659-bf54-daa393cba136)

The memo tests also assert the behaviour still updates where it should:
the last row shows its actions once the stream ends, the queue badge
still appears on the latest user row when the session queues, and a row
whose turn stats change re-renders (and only that row).

#### Targeted tests

```
$ timeout 600 npx vitest run "src/app/(platform)/copilot/components/ChatMessagesContainer" \
    "src/app/(platform)/copilot/__tests__/expertKickoff.test.ts" \
    "src/app/(platform)/copilot/useCopilotPendingChips.test.ts" --reporter=dot
 Test Files  23 passed (23)
      Tests  329 passed (329)

$ timeout 600 npx vitest run "src/app/(platform)/copilot/components/ToolChain" \
    "src/app/(platform)/copilot/components/ChatContainer" "src/app/(no-navbar)/share" --reporter=dot
 Test Files  35 passed (35)
      Tests  569 passed (569)
```

The same new tests against the `dev` versions of the five app files: 8
failed (all 5 memo tests, the kickoff identity test, the
ChainMessageParts identity test, the artifact-extraction test;
`midTurnSplit.test.ts` fails to import `buildMessageIndexById`).

`pnpm format`, `pnpm lint` and `pnpm types` are clean. `pnpm build`
(production) succeeds (exit 0).

### Follow-ups (out of scope, not fixed here)

- End of a turn: one full-transcript re-render remains because
`useCopilotPage` swaps `runtimeStream.turnStats` for
`historicalTurnStats` and the session refetch rebuilds every message
object (message pipeline, SECRT-2997's files).
- `answeredLabel(renderRows, rowIndex)` for held-call "answered" rows
still scans the transcript on every pass (cheap and rare, left inline).
- Inside the streaming row every part is a fresh object each flush (the
AI SDK `structuredClone`s the last message), so its
`MessagePartRenderer`s all render; only artifact extraction is skipped.
Per-part memoisation would need content comparison.
- `motion.div props:initial,animate` renders ~21 times per commit
somewhere outside the transcript; it now shows up higher only because
there are more commits.
- `pnpm perf` on current `dev` cannot finish `copilot-stream-reply`
inside its window (the "did not refetch" warning), so its baseline
numbers in `frontend-perf-baseline.md` no longer describe the same
window. After this PR the scenario completes again.

### Agents and large language models used

- Claude Code (Conductor) with Claude Opus 5.5 (1M context)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `copilot-stream-reply` and `copilot-type` before/after, 8 and 3
runs each side, alternated
- [x] Rendered DOM of the 200-message chat identical at rest and after a
streamed turn (apart from the elapsed time)
- [x] Streaming row is the only row that renders per token
(messageRowMemo test, 50 messages)
- [x] Stream end still shows the last row's actions; queue badge still
appears; changed turn stats still re-render their row
- [x] Targeted Vitest suites above pass; format, lint, types clean;
`pnpm build` succeeds

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**) (none)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Abhimanyu YadavandClaude Opus 5.5 authored and GitHub committed 2026-10-19 21:32:52 +00:00
1 parent 04db8d6b52
commit 8eb4062750
15 files changed
+1018 -278

No files matched your search

@@ -147,6 +147,15 @@ describe("kickoff message identification", () => {
expect(visible.map((message) => message.id)).toEqual(["m2"]);
});
it("returns the same array when there is no kickoff message to hide", () => {
const messages = [
userMessage("m1", "Plan my week"),
assistantMessage("m2", "Here's a plan."),
];
expect(hideKickoffMessages(messages)).toBe(messages);
});
it("keeps a user message that merely repeats the kickoff wording", () => {
const kickoff = buildKickoffMessage(EXPERT_ID);
@@ -7,18 +7,13 @@ import {
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageActions,
MessageContent,
} from "@/components/ai-elements/message";
import { Message, MessageContent } from "@/components/ai-elements/message";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { UIDataTypes, UIMessage, UITools } from "ai";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { TOOL_PART_PREFIX } from "../JobStatsBar/constants";
import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar";
import { useElapsedAnchor } from "../JobStatsBar/useElapsedTimer";
import { CopilotPendingReviews } from "../CopilotPendingReviews/CopilotPendingReviews";
import type { TurnStatsMap } from "../../helpers/convertChatSessionToUiMessages";
@@ -29,13 +24,9 @@ import {
countHeldCalls,
getHeldCallRowKind,
} from "./heldCallRows";
import { extractReviewTarget, getLatestCompactionPhase } from "./helpers";
import {
extractReviewTarget,
getLastCompactionCallId,
getLatestCompactionPhase,
getLatestCompactionStats,
} from "./helpers";
import {
buildMessageIndexById,
isMidTurnSegmentRow,
splitMessagesAtDrainHints,
turnMessagesForRow,
@@ -48,36 +39,18 @@ import { RESTORE_STALL_TIMEOUT_MS } from "../../restoreConstants";
import { TURN_PAUSED_PART_TYPE } from "../../turnPause";
import type { ExpertIdentity } from "../../useExpertMap";
import { ChatMinimap } from "../ChatMinimap/ChatMinimap";
import { WorkCard } from "../WorkCard/WorkCard";
import { getWorkRunMetadata, toPreview } from "../WorkCard/helpers";
import { AssistantMessageActions } from "./components/AssistantMessageActions";
import { ChainMessageParts } from "./components/ChainMessageParts";
import { withToolDisplayNames } from "../../helpers/toolDisplay";
import { CopyButton } from "./components/CopyButton";
import { getWorkRunMetadata } from "../WorkCard/helpers";
import { TailSpacer } from "./components/TailSpacer";
import { MessageAttachments } from "./components/MessageAttachments";
import { MessagePartRenderer } from "./components/MessagePartRenderer";
import { MessageRow } from "./components/MessageRow/MessageRow";
import { PendingAnswerContexts } from "./components/PendingAnswerContexts";
import { QueueBadge } from "./components/QueueBadge";
import { ThreadHeader, type FloatingControls } from "./components/ThreadHeader";
import {
PENDING_UPLOAD_MESSAGE_ID,
PendingUploadMessage,
} from "./components/PendingUploadMessage";
import { ThinkingIndicator } from "./components/ThinkingIndicator";
import { UserMessageClamp } from "./components/UserMessageClamp";
import { SentFromBadge } from "./components/SentFromBadge";
import { getVisibleUserMessageParts } from "./userMessageParts";
import {
getSentFromMetadata,
isSessionOpeningMessage,
type SentFrom,
} from "../../sentFrom";
import { isSessionOpeningMessage, type SentFrom } from "../../sentFrom";
import type { PendingUploadSend } from "../../copilotStreamStore";
import {
WORKSPACE_FOLDER_PART_TYPE,
type WorkspaceFolderPartData,
} from "../../helpers/workspaceAttachments";
import { Clock01Icon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/atoms/Icon/Icon";
import { ChatErrorBanner } from "./components/ChatErrorBanner/ChatErrorBanner";
@@ -334,7 +307,14 @@ export function ChatMessagesContainer({
// Rendered rows, not the array `useChat` owns: a turn whose pending buffer
// was drained mid-stream renders as chain → follow-up bubble → chain, while
// the underlying message stays whole. See `splitMessagesAtDrainHints`.
const renderRows = splitMessagesAtDrainHints(messages);
const renderRows = useMemo(
() => splitMessagesAtDrainHints(messages),
[messages],
);
const messageIndexById = useMemo(
() => buildMessageIndexById(messages),
[messages],
);
const lastMessage = messages[messages.length - 1];
const showPendingSend = !readOnly && !!pendingSend;
// Read off the rendered rows: a fallback follow-up row the split drops in
@@ -511,35 +491,11 @@ export function ChatMessagesContainer({
</div>
)}
{renderRows.map((message, rowIndex) => {
// A run-post rides structured metadata — render a compact WorkCard
// instead of the raw markdown wall (legacy posts have no metadata
// and fall through to normal rendering).
const runMetadata = getWorkRunMetadata(message.metadata);
if (runMetadata) {
const preview = toPreview(
message.parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
)
.map((p) => p.text)
.join(" "),
);
return (
<Message
from={message.role}
key={message.id}
data-message-id={message.id}
className="duration-300 animate-in fade-in slide-in-from-bottom-2 fill-mode-both"
>
<MessageContent className="group-[.is-assistant]:bg-transparent">
<WorkCard metadata={runMetadata} preview={preview} />
</MessageContent>
</Message>
);
}
const heldCallRow = getHeldCallRowKind(message.metadata);
// A run-post renders as a WorkCard inside the row, whatever else
// its metadata carries.
const heldCallRow = getWorkRunMetadata(message.metadata)
? null
: getHeldCallRowKind(message.metadata);
if (heldCallRow === "result") return null;
if (heldCallRow === "answered") {
return (
@@ -553,16 +509,10 @@ export function ChatMessagesContainer({
);
}
const isLastAssistant =
rowIndex === renderRows.length - 1 &&
message.role === "assistant";
const isCurrentlyStreaming =
isLastAssistant &&
(status === "streaming" || status === "submitted");
const isLastRow = rowIndex === renderRows.length - 1;
const isAssistant = message.role === "assistant";
const isLastAssistant = isLastRow && isAssistant;
const isCurrentlyStreaming = isLastAssistant && isActivelyStreaming;
const nextRow = renderRows[rowIndex + 1];
// A segment that only runs up to a mid-turn drain is never the end
// of its turn — the same backend turn continues under the follow-up
@@ -572,206 +522,38 @@ export function ChatMessagesContainer({
isAssistant &&
!isMidTurnSegmentRow(message) &&
(!nextRow || nextRow.role === "user");
// Bookkeeping parts are stripped before any render/split logic so
// they never reach the user UI, and so one landing between two
// tool calls can't split a chain. data-status surfaces via
// ThinkingIndicator; data-compaction via CompactionCard.
const renderableParts = withToolDisplayNames(
message.role === "user"
? getVisibleUserMessageParts(message.parts)
: message.parts,
).filter((p) => !isBookkeepingPart(p));
// Only a message that is actively streaming can have a live
// compaction phase — a stopped or failed turn must not leave an
// eternal progress bar. Replayed/settled messages never carry
// `data-compaction` parts, so they derive null either way.
const compactionPhase = isCurrentlyStreaming
? getLatestCompactionPhase(message.parts)
: null;
// The phase belongs to the LAST compaction row only; earlier
// (settled) rows in the same message stay settled.
const liveCompactionCallId =
compactionPhase !== null
? getLastCompactionCallId(message.parts)
: null;
// Stats streamed on the `data-compaction` parts pace the live
// progress curve while the tool row is still open (its own
// output stats only exist once it closes).
const liveCompactionStats =
compactionPhase !== null
? getLatestCompactionStats(message.parts)
: undefined;
const textParts = renderableParts.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
);
const lastTextPart = textParts[textParts.length - 1];
const markerType =
lastTextPart !== undefined
? parseSpecialMarkers(lastTextPart.text).markerType
: null;
const hasErrorMarker =
markerType === "error" || markerType === "retryable_error";
const showActions =
isLastInTurn &&
!isCurrentlyStreaming &&
textParts.length > 0 &&
!hasErrorMarker;
const fileParts = renderableParts.filter(
(p): p is FileUIPart => p.type === "file",
);
const folderParts = renderableParts.flatMap((p) =>
p.type === WORKSPACE_FOLDER_PART_TYPE
? [(p as { data: WorkspaceFolderPartData }).data]
: [],
);
const sentFrom = readOnly
? null
: (getSentFromMetadata(message.metadata) ??
(isSessionOpeningMessage(message) ? sessionSentFrom : null));
const showsTurnStats = isLastInTurn && !isCurrentlyStreaming;
return (
<Message
from={message.role}
<MessageRow
key={message.id}
data-message-id={message.id}
className="duration-300 animate-in fade-in slide-in-from-bottom-2 fill-mode-both"
>
<MessageContent
className={cn(
isCompact
? "text-sm leading-6 group-[.is-user]:rounded-xl"
: "text-[1rem] leading-relaxed group-[.is-user]:rounded-3xl",
"group-[.is-user]:bg-zinc-900 group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-zinc-50",
// Streamdown's links (buttons, `text-primary`) and light
// code chips would sink into the dark bubble.
"group-[.is-user]:[&_[data-streamdown=link]]:break-all group-[.is-user]:[&_[data-streamdown=link]]:text-zinc-50 group-[.is-user]:[&_[data-streamdown=link]]:underline group-[.is-user]:[&_code]:bg-white/10 group-[.is-user]:[&_code]:text-zinc-50",
"group-[.is-user]:[&_h1]:text-lg group-[.is-user]:[&_h1]:font-semibold group-[.is-user]:[&_h2]:text-lg group-[.is-user]:[&_h2]:font-semibold group-[.is-user]:[&_h3]:text-lg group-[.is-user]:[&_h3]:font-semibold group-[.is-user]:[&_h4]:text-lg group-[.is-user]:[&_h4]:font-semibold group-[.is-user]:[&_h5]:text-lg group-[.is-user]:[&_h5]:font-semibold group-[.is-user]:[&_h6]:text-lg group-[.is-user]:[&_h6]:font-semibold",
// Chain hover pills use negative margins that the base
// overflow-hidden would clip.
"group-[.is-assistant]:overflow-visible group-[.is-assistant]:bg-transparent group-[.is-assistant]:text-slate-900",
)}
>
{isAssistant ? (
<ChainMessageParts
parts={renderableParts}
messageID={message.id}
isCurrentlyStreaming={isCurrentlyStreaming}
onRetry={isLastAssistant ? onRetry : undefined}
fileUrlBuilder={fileUrlBuilder}
readOnly={readOnly}
compactionPhase={compactionPhase}
liveCompactionCallId={liveCompactionCallId}
liveCompactionStats={liveCompactionStats}
/>
) : (
<UserMessageClamp
trailing={
sentFrom ? <SentFromBadge sentFrom={sentFrom} /> : null
}
>
{renderableParts.map((part, i) => (
<MessagePartRenderer
key={`${message.id}-${i}`}
part={part}
messageID={message.id}
partIndex={i}
fileUrlBuilder={fileUrlBuilder}
readOnly={readOnly}
compactionPhase={compactionPhase}
liveCompactionCallId={liveCompactionCallId}
liveCompactionStats={liveCompactionStats}
isCurrentlyStreaming={isCurrentlyStreaming}
/>
))}
</UserMessageClamp>
)}
{isLastInTurn && !isCurrentlyStreaming && (
<TurnStatsBar
turnMessages={turnMessagesForRow(messages, message)}
elapsedSeconds={
rowIndex === renderRows.length - 1
? streamClock.frozenSeconds
: undefined
}
stats={turnStats?.get(message.id)}
/>
)}
{isLastAssistant && showIndicator && indicator}
</MessageContent>
{!readOnly &&
message.role === "user" &&
sessionChatStatus === "queued" &&
(() => {
const stats = turnStats?.get(message.id);
if (!stats?.isLatestUserMessage) {
return null;
}
return (
<MessageActions
className="mt-1 items-center justify-end gap-1.5"
data-testid="queue-status-row"
>
<QueueBadge sessionID={sessionID ?? null} />
</MessageActions>
);
})()}
{message.role === "user" && textParts.length > 0 && (
<MessageActions className="mt-1 items-center justify-end gap-2 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
{(() => {
const createdAt = turnStats?.get(message.id)?.createdAt;
if (!createdAt) return null;
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return null;
return (
<span className="text-[11px] tabular-nums text-neutral-500">
{date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})}
</span>
);
})()}
<CopyButton
text={textParts.map((p) => p.text).join("\n")}
/>
</MessageActions>
)}
{(fileParts.length > 0 || folderParts.length > 0) && (
<MessageAttachments
files={fileParts}
folders={folderParts}
isUser={message.role === "user"}
filePattern={filePattern}
readOnly={readOnly}
/>
)}
{!readOnly && showActions && (
<AssistantMessageActions
message={message}
sessionID={sessionID ?? null}
className={cn(
!isLastAssistant &&
"opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100",
)}
/>
)}
{readOnly && showActions && (
<MessageActions
className={cn(
"mt-1 items-center justify-start gap-2",
!isLastAssistant &&
"opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100",
)}
>
<CopyButton
text={textParts.map((p) => p.text).join("\n")}
/>
</MessageActions>
)}
</Message>
message={message}
isLastAssistant={isLastAssistant}
isCurrentlyStreaming={isCurrentlyStreaming}
isLastInTurn={isLastInTurn}
turnStat={turnStats?.get(message.id)}
turnMessages={
showsTurnStats
? turnMessagesForRow(messages, message, messageIndexById)
: undefined
}
elapsedSeconds={
isLastRow ? streamClock.frozenSeconds : undefined
}
indicator={isLastAssistant && showIndicator ? indicator : null}
isSessionQueued={
message.role === "user" && sessionChatStatus === "queued"
}
sessionSentFrom={
isSessionOpeningMessage(message) ? sessionSentFrom : null
}
sessionID={sessionID}
readOnly={readOnly}
compact={isCompact}
filePattern={filePattern}
fileUrlBuilder={fileUrlBuilder}
onRetry={isLastAssistant ? onRetry : undefined}
/>
);
})}
{showPendingSend && pendingSend && (
@@ -0,0 +1,274 @@
import type { ComponentProps, ReactNode } from "react";
import type { UIDataTypes, UIMessage, UITools } from "ai";
import { cleanup, render, screen } from "@/tests/integrations/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TurnStatsMap } from "../../../helpers/convertChatSessionToUiMessages";
import { ChatMessagesContainer } from "../ChatMessagesContainer";
type Message = UIMessage<unknown, UIDataTypes, UITools>;
// Each row draws its parts through one of these two, so counting their
// renders per message id counts which transcript rows rendered.
const rowRenders = new Map<string, number>();
function countRender(messageID: string) {
rowRenders.set(messageID, (rowRenders.get(messageID) ?? 0) + 1);
}
vi.mock("@/services/feature-flags/use-get-flag", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/services/feature-flags/use-get-flag")
>();
return { ...actual, useGetFlag: () => false };
});
vi.mock("use-stick-to-bottom", () => ({
useStickToBottomContext: () => ({
scrollRef: {
current: { scrollHeight: 100, scrollTop: 0, clientHeight: 500 },
},
}),
}));
vi.mock("@/components/ai-elements/conversation", () => ({
Conversation: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ConversationContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ConversationScrollButton: () => null,
}));
vi.mock("@/components/ai-elements/message", () => ({
Message: ({
children,
"data-message-id": messageID,
}: {
children: ReactNode;
"data-message-id"?: string;
}) => <div data-message-id={messageID}>{children}</div>,
MessageActions: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
MessageContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../components/ChainMessageParts", () => ({
ChainMessageParts: ({
messageID,
parts,
}: {
messageID: string;
parts: { type: string; text?: string }[];
}) => {
countRender(messageID);
return (
<div data-testid={`chain-${messageID}`}>
{parts.map((part) => part.text ?? "").join("")}
</div>
);
},
}));
vi.mock("../components/MessagePartRenderer", () => ({
MessagePartRenderer: ({
messageID,
part,
}: {
messageID: string;
part: { type: string; text?: string };
}) => {
countRender(messageID);
return part.type === "text" ? <span>{part.text}</span> : null;
},
}));
vi.mock("../components/AssistantMessageActions", () => ({
AssistantMessageActions: ({ message }: { message: Message }) => (
<div data-testid={`actions-${message.id}`} />
),
}));
vi.mock("../components/CopyButton", () => ({
CopyButton: () => null,
}));
vi.mock("../components/QueueBadge", () => ({
QueueBadge: () => <span data-testid="queue-badge" />,
}));
vi.mock("../components/MessageAttachments", () => ({
MessageAttachments: () => null,
}));
vi.mock("../../JobStatsBar/TurnStatsBar", () => ({
TurnStatsBar: () => null,
}));
vi.mock("../../CopilotPendingReviews/CopilotPendingReviews", () => ({
CopilotPendingReviews: () => null,
}));
vi.mock("../../ChatMinimap/ChatMinimap", () => ({
ChatMinimap: () => null,
}));
vi.mock("../../PixelGridLoader/PixelGridLoader", () => ({
PixelGridLoader: () => null,
}));
class MockIntersectionObserver {
observe() {}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
root = null;
rootMargin = "";
thresholds = [];
}
function buildTranscript(count: number): Message[] {
return Array.from({ length: count }, (_, i) => ({
id: `m-${i}`,
role: i % 2 === 0 ? "user" : "assistant",
parts: [{ type: "text", text: `message ${i}` }],
}));
}
function appendToken(messages: Message[], token: string): Message[] {
const last = messages[messages.length - 1];
const part = last.parts[0] as { type: "text"; text: string };
return [
...messages.slice(0, -1),
{ ...last, parts: [{ ...part, text: part.text + token }] },
];
}
function renderedRows(): string[] {
return [...rowRenders.keys()];
}
const baseProps = {
messages: buildTranscript(50),
status: "streaming",
error: undefined,
isLoading: false,
sessionID: "sess-1",
sessionChatStatus: "running",
queuedMessages: [] as string[],
hasMoreMessages: false,
isLoadingMore: false,
onLoadMore: vi.fn(),
onRetry: vi.fn(),
} satisfies ComponentProps<typeof ChatMessagesContainer>;
describe("ChatMessagesContainer — memoised transcript rows", () => {
beforeEach(() => {
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
rowRenders.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("re-renders only the streaming row when a token lands", () => {
const { rerender } = render(<ChatMessagesContainer {...baseProps} />);
expect(rowRenders.size).toBe(50);
rowRenders.clear();
const next = appendToken(baseProps.messages, " more");
rerender(<ChatMessagesContainer {...baseProps} messages={next} />);
expect(renderedRows()).toEqual(["m-49"]);
expect(screen.getByTestId("chain-m-49").textContent).toBe(
"message 49 more",
);
});
it("re-renders no row when the parent re-renders with the same messages", () => {
const { rerender } = render(<ChatMessagesContainer {...baseProps} />);
rowRenders.clear();
rerender(
<ChatMessagesContainer
{...baseProps}
sessionChatStatus="idle"
turnStats={new Map()}
/>,
);
expect(renderedRows()).toEqual([]);
});
it("re-renders a row whose turn stats changed, and no other", () => {
const stats = (createdAt: string): TurnStatsMap =>
new Map([
["m-10", { createdAt }],
["m-20", { createdAt: "2026-10-01T10:00:00Z" }],
]);
const { rerender } = render(
<ChatMessagesContainer
{...baseProps}
status="ready"
turnStats={stats("2026-10-01T09:00:00Z")}
/>,
);
rowRenders.clear();
rerender(
<ChatMessagesContainer
{...baseProps}
status="ready"
turnStats={stats("2026-10-01T09:00:00Z")}
/>,
);
expect(renderedRows()).toEqual([]);
rerender(
<ChatMessagesContainer
{...baseProps}
status="ready"
turnStats={stats("2026-10-02T09:00:00Z")}
/>,
);
expect(renderedRows()).toEqual(["m-10"]);
});
it("still updates the last row when the stream ends", () => {
const { rerender } = render(<ChatMessagesContainer {...baseProps} />);
expect(screen.queryByTestId("actions-m-49")).toBeNull();
rowRenders.clear();
rerender(<ChatMessagesContainer {...baseProps} status="ready" />);
expect(renderedRows()).toEqual(["m-49"]);
expect(screen.getByTestId("actions-m-49")).toBeDefined();
});
it("still shows the queue badge on the latest user row once the session queues", () => {
const turnStats: TurnStatsMap = new Map([
["m-48", { isLatestUserMessage: true }],
]);
const { rerender } = render(
<ChatMessagesContainer
{...baseProps}
status="ready"
turnStats={turnStats}
/>,
);
expect(screen.queryByTestId("queue-badge")).toBeNull();
rowRenders.clear();
rerender(
<ChatMessagesContainer
{...baseProps}
status="ready"
sessionChatStatus="queued"
turnStats={turnStats}
/>,
);
expect(screen.getByTestId("queue-badge")).toBeDefined();
expect(renderedRows().every((id) => Number(id.slice(2)) % 2 === 0)).toBe(
true,
);
});
});
@@ -4,10 +4,12 @@ import { describe, expect, it } from "vitest";
import { makePromotedUserBubble } from "../../../helpers/makePromotedBubble";
import {
asMidTurnFallbackRow,
buildMessageIndexById,
isMidTurnFallbackRow,
isMidTurnSegmentRow,
messagesCarryDrainedText,
splitMessagesAtDrainHints,
turnMessagesForRow,
} from "../midTurnSplit";
type ChatMessage = UIMessage<unknown, UIDataTypes, UITools>;
@@ -435,3 +437,55 @@ describe("splitMessagesAtDrainHints", () => {
]);
});
});
describe("splitMessagesAtDrainHints — row identity", () => {
it("returns the same segment rows for an unchanged message", () => {
const drained = assistant("assistant-1", [
toolPart("search"),
hintPart([{ id: "p1", content: "also check Friday" }]),
textPart("Done."),
]);
const first = splitMessagesAtDrainHints([PROMPT, drained]);
const second = splitMessagesAtDrainHints([
PROMPT,
drained,
user("user-2", "thanks"),
]);
expect(first.map((row) => row.id)).toEqual(
second.slice(0, first.length).map((row) => row.id),
);
first.forEach((row, i) => expect(second[i]).toBe(row));
});
});
describe("turnMessagesForRow", () => {
const answer = assistant("assistant-1", [textPart("Done.")]);
const followUp = user("user-2", "thanks");
const reply = assistant("assistant-2", [textPart("Any time.")]);
const messages = [PROMPT, answer, followUp, reply];
const indexById = buildMessageIndexById(messages);
it("returns the turn the row closes", () => {
expect(turnMessagesForRow(messages, answer, indexById)).toEqual([
PROMPT,
answer,
]);
expect(turnMessagesForRow(messages, reply, indexById)).toEqual([
followUp,
reply,
]);
});
it("falls back to the row alone when it is not a source message", () => {
const segment = assistant("assistant-1#seg0", [textPart("Half.")]);
expect(turnMessagesForRow(messages, segment, indexById)).toEqual([segment]);
});
it("indexes a repeated id at its first position, as findIndex would", () => {
const index = buildMessageIndexById([PROMPT, answer, answer]);
expect(index.get("assistant-1")).toBe(1);
});
});
@@ -1,5 +1,6 @@
import { isChainableToolPart, type MessagePart } from "../helpers";
import type { ToolUIPart } from "ai";
import { useMemo } from "react";
import { ExpertChangeGroup } from "../../ToolChain/ExpertCards";
import { buildChainSegments } from "../../ToolChain/helpers";
import { ToolChain } from "../../ToolChain/ToolChain";
@@ -32,7 +33,12 @@ export function ChainMessageParts({
liveCompactionCallId,
liveCompactionStats,
}: Props) {
const segments = buildChainSegments(parts, isChainableToolPart);
// Kept per `parts` so each ToolChain gets the same `parts` array back and
// its own row derivation stays memoised.
const segments = useMemo(
() => buildChainSegments(parts, isChainableToolPart),
[parts],
);
const lastChainSegmentIndex = segments.findLastIndex(
(segment) => segment.kind === "chain",
);
@@ -3,6 +3,7 @@ import { CredentialMentionMarkdown } from "../../CredentialMention/CredentialMen
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { StoppedTaskCard } from "./StoppedTaskCard";
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { useMemo } from "react";
import { ArtifactCard } from "../../ArtifactCard/ArtifactCard";
import { AskQuestionTool } from "../../../tools/AskQuestion/AskQuestion";
import { ConnectIntegrationTool } from "../../../tools/ConnectIntegrationTool/ConnectIntegrationTool";
@@ -91,8 +92,16 @@ function TextWithArtifactCards({
fileUrlBuilder?: (fileId: string) => string;
readOnly?: boolean;
}) {
const artifacts = extractWorkspaceArtifacts(text, fileUrlBuilder);
const resolved = resolveWorkspaceUrls(text, fileUrlBuilder);
// A settled part's text never changes, so this only re-runs for the part
// that is streaming.
const artifacts = useMemo(
() => extractWorkspaceArtifacts(text, fileUrlBuilder),
[text, fileUrlBuilder],
);
const resolved = useMemo(
() => resolveWorkspaceUrls(text, fileUrlBuilder),
[text, fileUrlBuilder],
);
// Text reads first, with the artifact cards trailing.
return (
@@ -0,0 +1,200 @@
import { memo, type ReactNode } from "react";
import type { UIDataTypes, UIMessage, UITools } from "ai";
import { Message, MessageContent } from "@/components/ai-elements/message";
import type { TurnStats } from "../../../../helpers/convertChatSessionToUiMessages";
import type { SentFrom } from "../../../../sentFrom";
import { TurnStatsBar } from "../../../JobStatsBar/TurnStatsBar";
import { WorkCard } from "../../../WorkCard/WorkCard";
import { ChainMessageParts } from "../ChainMessageParts";
import { MessageAttachments } from "../MessageAttachments";
import { MessagePartRenderer } from "../MessagePartRenderer";
import { SentFromBadge } from "../SentFromBadge";
import { UserMessageClamp } from "../UserMessageClamp";
import { AssistantRowActions } from "./components/AssistantRowActions";
import { UserRowActions } from "./components/UserRowActions";
import { areMessageRowPropsEqual, getMessageContentClassName } from "./helpers";
import { useMessageRow } from "./useMessageRow";
type ChatMessage = UIMessage<unknown, UIDataTypes, UITools>;
/**
* One transcript row. Memoised: while a reply streams only the row whose
* props change re-renders, so every prop must keep its identity between
* passes unless the row really changed. Pass row-specific values (`undefined`
* or `null` on rows they don't apply to) rather than session-wide ones.
*/
interface Props {
/** The rendered row: a message, or one segment of a turn split at a
* mid-turn follow-up. */
message: ChatMessage;
/** Last rendered row and an assistant one. Only this row gets `onRetry`
* and keeps its actions visible without hover. */
isLastAssistant: boolean;
/** `isLastAssistant` while the chat status is streaming or submitted. */
isCurrentlyStreaming: boolean;
/** Assistant row that closes its turn: shows the stats bar and actions. */
isLastInTurn: boolean;
/** This row's entry from the turn stats map. */
turnStat?: TurnStats;
/** The turn this row closes, for the stats bar. Only set when the bar
* shows (`isLastInTurn` and not streaming). Compared by elements. */
turnMessages?: ChatMessage[];
/** Live elapsed seconds; only the last row gets them. */
elapsedSeconds?: number;
/** Thinking indicator rendered at the bottom of the last assistant row. */
indicator?: ReactNode;
/** User row while the session is queued; the queue badge shows on the
* latest user message. */
isSessionQueued: boolean;
/** Session-level provenance, used only by the row that opened the thread. */
sessionSentFrom: SentFrom | null;
sessionID?: string | null;
readOnly: boolean;
compact: boolean;
filePattern?: RegExp;
fileUrlBuilder?: (fileId: string) => string;
onRetry?: () => void;
}
export const MessageRow = memo(function MessageRow({
message,
isLastAssistant,
isCurrentlyStreaming,
isLastInTurn,
turnStat,
turnMessages,
elapsedSeconds,
indicator,
isSessionQueued,
sessionSentFrom,
sessionID,
readOnly,
compact,
filePattern,
fileUrlBuilder,
onRetry,
}: Props) {
const {
runMetadata,
runPreview,
renderableParts,
textParts,
copyText,
hasErrorMarker,
fileParts,
folderParts,
compactionPhase,
liveCompactionCallId,
liveCompactionStats,
sentFrom,
} = useMessageRow({
message,
isCurrentlyStreaming,
readOnly,
sessionSentFrom,
});
// A run-post rides structured metadata — render a compact WorkCard
// instead of the raw markdown wall (legacy posts have no metadata and
// fall through to normal rendering).
if (runMetadata) {
return (
<Message
from={message.role}
data-message-id={message.id}
className="duration-300 animate-in fade-in slide-in-from-bottom-2 fill-mode-both"
>
<MessageContent className="group-[.is-assistant]:bg-transparent">
<WorkCard metadata={runMetadata} preview={runPreview} />
</MessageContent>
</Message>
);
}
const isAssistant = message.role === "assistant";
const showActions =
isLastInTurn &&
!isCurrentlyStreaming &&
textParts.length > 0 &&
!hasErrorMarker;
return (
<Message
from={message.role}
data-message-id={message.id}
className="duration-300 animate-in fade-in slide-in-from-bottom-2 fill-mode-both"
>
<MessageContent className={getMessageContentClassName(compact)}>
{isAssistant ? (
<ChainMessageParts
parts={renderableParts}
messageID={message.id}
isCurrentlyStreaming={isCurrentlyStreaming}
onRetry={isLastAssistant ? onRetry : undefined}
fileUrlBuilder={fileUrlBuilder}
readOnly={readOnly}
compactionPhase={compactionPhase}
liveCompactionCallId={liveCompactionCallId}
liveCompactionStats={liveCompactionStats}
/>
) : (
<UserMessageClamp
trailing={sentFrom ? <SentFromBadge sentFrom={sentFrom} /> : null}
>
{renderableParts.map((part, i) => (
<MessagePartRenderer
key={`${message.id}-${i}`}
part={part}
messageID={message.id}
partIndex={i}
fileUrlBuilder={fileUrlBuilder}
readOnly={readOnly}
compactionPhase={compactionPhase}
liveCompactionCallId={liveCompactionCallId}
liveCompactionStats={liveCompactionStats}
isCurrentlyStreaming={isCurrentlyStreaming}
/>
))}
</UserMessageClamp>
)}
{isLastInTurn && !isCurrentlyStreaming && (
<TurnStatsBar
turnMessages={turnMessages ?? [message]}
elapsedSeconds={elapsedSeconds}
stats={turnStat}
/>
)}
{indicator}
</MessageContent>
{message.role === "user" && (
<UserRowActions
showQueueBadge={
!readOnly && isSessionQueued && !!turnStat?.isLatestUserMessage
}
showCopy={textParts.length > 0}
createdAt={turnStat?.createdAt}
copyText={copyText}
sessionID={sessionID}
/>
)}
{(fileParts.length > 0 || folderParts.length > 0) && (
<MessageAttachments
files={fileParts}
folders={folderParts}
isUser={message.role === "user"}
filePattern={filePattern}
readOnly={readOnly}
/>
)}
{showActions && (
<AssistantRowActions
message={message}
isLastAssistant={isLastAssistant}
readOnly={readOnly}
copyText={copyText}
sessionID={sessionID}
/>
)}
</Message>
);
}, areMessageRowPropsEqual);
@@ -0,0 +1,44 @@
import type { UIDataTypes, UIMessage, UITools } from "ai";
import { MessageActions } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import { AssistantMessageActions } from "../../AssistantMessageActions";
import { CopyButton } from "../../CopyButton";
interface Props {
message: UIMessage<unknown, UIDataTypes, UITools>;
isLastAssistant: boolean;
readOnly: boolean;
copyText: string;
sessionID?: string | null;
}
const HOVER_REVEAL =
"opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100";
export function AssistantRowActions({
message,
isLastAssistant,
readOnly,
copyText,
sessionID,
}: Props) {
if (!readOnly) {
return (
<AssistantMessageActions
message={message}
sessionID={sessionID ?? null}
className={cn(!isLastAssistant && HOVER_REVEAL)}
/>
);
}
return (
<MessageActions
className={cn(
"mt-1 items-center justify-start gap-2",
!isLastAssistant && HOVER_REVEAL,
)}
>
<CopyButton text={copyText} />
</MessageActions>
);
}
@@ -0,0 +1,46 @@
import { MessageActions } from "@/components/ai-elements/message";
import { CopyButton } from "../../CopyButton";
import { QueueBadge } from "../../QueueBadge";
interface Props {
showQueueBadge: boolean;
showCopy: boolean;
createdAt?: string;
copyText: string;
sessionID?: string | null;
}
export function UserRowActions({
showQueueBadge,
showCopy,
createdAt,
copyText,
sessionID,
}: Props) {
const date = createdAt ? new Date(createdAt) : null;
return (
<>
{showQueueBadge && (
<MessageActions
className="mt-1 items-center justify-end gap-1.5"
data-testid="queue-status-row"
>
<QueueBadge sessionID={sessionID ?? null} />
</MessageActions>
)}
{showCopy && (
<MessageActions className="mt-1 items-center justify-end gap-2 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
{date && !Number.isNaN(date.getTime()) && (
<span className="text-[11px] tabular-nums text-neutral-500">
{date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})}
</span>
)}
<CopyButton text={copyText} />
</MessageActions>
)}
</>
);
}
@@ -0,0 +1,161 @@
import type { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { cn } from "@/lib/utils";
import { parseSpecialMarkers } from "../../../../helpers/messageMarkers";
import { withToolDisplayNames } from "../../../../helpers/toolDisplay";
import type { TurnStats } from "../../../../helpers/convertChatSessionToUiMessages";
import {
WORKSPACE_FOLDER_PART_TYPE,
type WorkspaceFolderPartData,
} from "../../../../helpers/workspaceAttachments";
import { isBookkeepingPart } from "../../../../messageParts";
import type { SentFrom } from "../../../../sentFrom";
import {
getLastCompactionCallId,
getLatestCompactionPhase,
getLatestCompactionStats,
type MessagePart,
} from "../../helpers";
import { getVisibleUserMessageParts } from "../../userMessageParts";
type ChatMessage = UIMessage<unknown, UIDataTypes, UITools>;
type TextPart = Extract<MessagePart, { type: "text" }>;
export function deriveRowParts(
role: ChatMessage["role"],
parts: MessagePart[],
) {
// Bookkeeping parts are stripped before any render/split logic so they
// never reach the user UI, and so one landing between two tool calls
// can't split a chain. data-status surfaces via ThinkingIndicator;
// data-compaction via CompactionCard.
const renderableParts = withToolDisplayNames(
role === "user" ? getVisibleUserMessageParts(parts) : parts,
).filter((p) => !isBookkeepingPart(p));
const textParts = renderableParts.filter(
(p): p is TextPart => p.type === "text",
);
const lastTextPart = textParts[textParts.length - 1];
const markerType =
lastTextPart !== undefined
? parseSpecialMarkers(lastTextPart.text).markerType
: null;
const fileParts = renderableParts.filter(
(p): p is FileUIPart => p.type === "file",
);
const folderParts = renderableParts.flatMap((p) =>
p.type === WORKSPACE_FOLDER_PART_TYPE
? [(p as { data: WorkspaceFolderPartData }).data]
: [],
);
return {
renderableParts,
textParts,
copyText: textParts.map((p) => p.text).join("\n"),
hasErrorMarker: markerType === "error" || markerType === "retryable_error",
fileParts,
folderParts,
};
}
// Only a message that is actively streaming can have a live compaction
// phase — a stopped or failed turn must not leave an eternal progress bar.
// Replayed/settled messages never carry `data-compaction` parts, so they
// derive null either way.
export function deriveLiveCompaction(
parts: MessagePart[],
isCurrentlyStreaming: boolean,
) {
const compactionPhase = isCurrentlyStreaming
? getLatestCompactionPhase(parts)
: null;
if (compactionPhase === null) {
return {
compactionPhase,
liveCompactionCallId: null,
liveCompactionStats: undefined,
};
}
return {
compactionPhase,
// The phase belongs to the LAST compaction row only; earlier (settled)
// rows in the same message stay settled.
liveCompactionCallId: getLastCompactionCallId(parts),
// Stats streamed on the `data-compaction` parts pace the live progress
// curve while the tool row is still open (its own output stats only
// exist once it closes).
liveCompactionStats: getLatestCompactionStats(parts),
};
}
export function getMessageContentClassName(compact: boolean): string {
return cn(
compact
? "text-sm leading-6 group-[.is-user]:rounded-xl"
: "text-[1rem] leading-relaxed group-[.is-user]:rounded-3xl",
"group-[.is-user]:bg-zinc-900 group-[.is-user]:px-4 group-[.is-user]:py-2.5 group-[.is-user]:text-zinc-50",
// Streamdown's links (buttons, `text-primary`) and light
// code chips would sink into the dark bubble.
"group-[.is-user]:[&_[data-streamdown=link]]:break-all group-[.is-user]:[&_[data-streamdown=link]]:text-zinc-50 group-[.is-user]:[&_[data-streamdown=link]]:underline group-[.is-user]:[&_code]:bg-white/10 group-[.is-user]:[&_code]:text-zinc-50",
"group-[.is-user]:[&_h1]:text-lg group-[.is-user]:[&_h1]:font-semibold group-[.is-user]:[&_h2]:text-lg group-[.is-user]:[&_h2]:font-semibold group-[.is-user]:[&_h3]:text-lg group-[.is-user]:[&_h3]:font-semibold group-[.is-user]:[&_h4]:text-lg group-[.is-user]:[&_h4]:font-semibold group-[.is-user]:[&_h5]:text-lg group-[.is-user]:[&_h5]:font-semibold group-[.is-user]:[&_h6]:text-lg group-[.is-user]:[&_h6]:font-semibold",
// Chain hover pills use negative margins that the base
// overflow-hidden would clip.
"group-[.is-assistant]:overflow-visible group-[.is-assistant]:bg-transparent group-[.is-assistant]:text-slate-900",
);
}
export function getRunPreview(parts: MessagePart[]): string {
return parts
.filter((p): p is TextPart => p.type === "text")
.map((p) => p.text)
.join(" ");
}
function isSameArray<T>(a: T[] | undefined, b: T[] | undefined): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
return a.every((item, i) => item === b[i]);
}
function isShallowEqual(
a: object | null | undefined,
b: object | null | undefined,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) return false;
return aKeys.every((key) =>
Object.is(a[key as keyof typeof a], b[key as keyof typeof b]),
);
}
interface ComparableRowProps {
turnMessages?: ChatMessage[];
turnStat?: TurnStats;
sessionSentFrom?: SentFrom | null;
}
// The parent rebuilds these three on every pass (a sliced turn, a stats
// entry from a rebuilt map, a session-level object), so they compare by
// content; every other prop compares by identity.
const VALUE_COMPARED_PROPS = new Set<string>([
"turnMessages",
"turnStat",
"sessionSentFrom",
]);
export function areMessageRowPropsEqual<P extends ComparableRowProps>(
prev: P,
next: P,
): boolean {
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const key of keys) {
if (VALUE_COMPARED_PROPS.has(key)) continue;
if (!Object.is(prev[key as keyof P], next[key as keyof P])) return false;
}
return (
isSameArray(prev.turnMessages, next.turnMessages) &&
isShallowEqual(prev.turnStat, next.turnStat) &&
isShallowEqual(prev.sessionSentFrom, next.sessionSentFrom)
);
}
@@ -0,0 +1,42 @@
import type { UIDataTypes, UIMessage, UITools } from "ai";
import { useMemo } from "react";
import {
getSentFromMetadata,
isSessionOpeningMessage,
type SentFrom,
} from "../../../../sentFrom";
import { getWorkRunMetadata, toPreview } from "../../../WorkCard/helpers";
import { deriveLiveCompaction, deriveRowParts, getRunPreview } from "./helpers";
interface Args {
message: UIMessage<unknown, UIDataTypes, UITools>;
isCurrentlyStreaming: boolean;
readOnly: boolean;
sessionSentFrom: SentFrom | null;
}
export function useMessageRow({
message,
isCurrentlyStreaming,
readOnly,
sessionSentFrom,
}: Args) {
const { role, parts, metadata } = message;
const runMetadata = useMemo(() => getWorkRunMetadata(metadata), [metadata]);
const runPreview = useMemo(
() => (runMetadata ? toPreview(getRunPreview(parts)) : ""),
[runMetadata, parts],
);
const rowParts = useMemo(() => deriveRowParts(role, parts), [role, parts]);
const compaction = useMemo(
() => deriveLiveCompaction(parts, isCurrentlyStreaming),
[parts, isCurrentlyStreaming],
);
const sentFrom = readOnly
? null
: (getSentFromMetadata(metadata) ??
(isSessionOpeningMessage(message) ? sessionSentFrom : null));
return { runMetadata, runPreview, ...rowParts, ...compaction, sentFrom };
}
@@ -3,10 +3,21 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { MessagePart } from "../../helpers";
import { ChainMessageParts } from "../ChainMessageParts";
const toolChainParts: unknown[] = [];
vi.mock("../../../ToolChain/ToolChain", () => ({
ToolChain: ({ isStreaming }: { isStreaming: boolean }) => (
<div data-testid="tool-chain" data-streaming={String(isStreaming)} />
),
ToolChain: ({
isStreaming,
parts,
}: {
isStreaming: boolean;
parts: unknown;
}) => {
toolChainParts.push(parts);
return (
<div data-testid="tool-chain" data-streaming={String(isStreaming)} />
);
},
}));
vi.mock("../MessagePartRenderer", () => ({
@@ -143,4 +154,35 @@ describe("ChainMessageParts", () => {
expect(screen.getAllByTestId("tool-chain").length).toBe(1);
expect(screen.queryByTestId("expert-group")).toBeNull();
});
it("hands each chain the same parts while the message's parts are unchanged", () => {
const parts = [
{
type: "tool-web_search",
state: "output-available",
toolCallId: "search",
input: {},
output: {},
},
] as MessagePart[];
toolChainParts.length = 0;
const { rerender } = render(
<ChainMessageParts
parts={parts}
messageID="message"
isCurrentlyStreaming
/>,
);
rerender(
<ChainMessageParts
parts={parts}
messageID="message"
isCurrentlyStreaming={false}
/>,
);
expect(toolChainParts).toHaveLength(2);
expect(toolChainParts[1]).toBe(toolChainParts[0]);
});
});
@@ -49,6 +49,16 @@ vi.mock("../StoppedTaskCard", () => ({
StoppedTaskCard: () => <div data-testid="stopped-task-card" />,
}));
const extractWorkspaceArtifactsSpy = vi.hoisted(() => vi.fn());
vi.mock("../../helpers", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../helpers")>();
extractWorkspaceArtifactsSpy.mockImplementation(
actual.extractWorkspaceArtifacts,
);
return { ...actual, extractWorkspaceArtifacts: extractWorkspaceArtifactsSpy };
});
function stubTool(name: string, testid: string) {
return { [name]: () => <div data-testid={testid} /> };
}
@@ -228,6 +238,37 @@ describe("MessagePartRenderer text branch", () => {
expect(screen.getByTestId("artifact-card")).toBeDefined();
});
it("does not re-extract artifacts when the part re-renders with the same text", () => {
const fileId = "550e8400-e29b-41d4-a716-446655440000";
const text = `Here is [report](workspace://${fileId}).`;
extractWorkspaceArtifactsSpy.mockClear();
const { rerender } = render(
<MessagePartRenderer
part={{ type: "text", text } as unknown as Part}
messageID="m1"
partIndex={0}
/>,
);
rerender(
<MessagePartRenderer
part={{ type: "text", text } as unknown as Part}
messageID="m1"
partIndex={0}
/>,
);
expect(extractWorkspaceArtifactsSpy).toHaveBeenCalledTimes(1);
rerender(
<MessagePartRenderer
part={{ type: "text", text: `${text} More.` } as unknown as Part}
messageID="m1"
partIndex={0}
/>,
);
expect(extractWorkspaceArtifactsSpy).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("artifact-card")).toBeDefined();
});
it("renders the StoppedTaskCard for a cancellation marker", () => {
const part = {
type: "text",
@@ -78,7 +78,7 @@ export function splitMessagesAtDrainHints(
rows.push(message);
continue;
}
const segments = splitAssistantMessage(message);
const segments = splitAssistantMessageCached(message);
dropFallbackRowsDrawnBy(rows, segments);
rows.push(...segments);
}
@@ -146,6 +146,19 @@ function userText(message: ChatMessage): string {
.join("");
}
// A message's split depends on nothing but the message, so the segments are
// kept per message object: rows of a settled turn keep their identity while
// a later turn streams, and their memoised row does not re-render.
const splitCache = new WeakMap<ChatMessage, ChatMessage[]>();
function splitAssistantMessageCached(message: ChatMessage): ChatMessage[] {
const cached = splitCache.get(message);
if (cached) return cached;
const segments = splitAssistantMessage(message);
splitCache.set(message, segments);
return segments;
}
function splitAssistantMessage(message: ChatMessage): ChatMessage[] {
const rows: ChatMessage[] = [];
let segmentParts: MessagePart[] = [];
@@ -261,7 +274,21 @@ function readDrainedMessages(part: MessagePart): DrainedMessage[] {
export function turnMessagesForRow(
messages: ChatMessage[],
row: ChatMessage,
indexById: ReadonlyMap<string, number>,
): ChatMessage[] {
const sourceIndex = messages.findIndex((m) => m.id === row.id);
const sourceIndex = indexById.get(row.id) ?? -1;
return sourceIndex === -1 ? [row] : getTurnMessages(messages, sourceIndex);
}
/** Position of each message by id, so a row finds its source message without
* a scan. Build it once per `messages`. On a repeated id the first one wins,
* as `findIndex` would. */
export function buildMessageIndexById(
messages: ChatMessage[],
): Map<string, number> {
const indexById = new Map<string, number>();
messages.forEach((message, index) => {
if (!indexById.has(message.id)) indexById.set(message.id, index);
});
return indexById;
}
@@ -131,7 +131,10 @@ export function shouldClearKickoffParam(
// It reads as an instruction, not as something the user typed, so the thread
// never shows it. The backend marks the persisted row hidden for the same
// reason.
// Returns `messages` itself when there is nothing to hide, so memoised
// consumers keyed on the array see no change.
export function hideKickoffMessages<T extends UIMessage>(messages: T[]): T[] {
if (!messages.some(isKickoffMessage)) return messages;
return messages.filter((message) => !isKickoffMessage(message));
}