mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 12:51:43 -10:00
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).  After: the whole reply is drawn, the turn has ended and the actions show.  #### 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"  After: "Thought for 7s", otherwise identical  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:
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);
|
||||
|
||||
|
||||
+52
-270
@@ -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 && (
|
||||
|
||||
+274
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+54
@@ -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);
|
||||
});
|
||||
});
|
||||
+7
-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",
|
||||
);
|
||||
|
||||
+11
-2
@@ -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 (
|
||||
|
||||
+200
@@ -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);
|
||||
+44
@@ -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>
|
||||
);
|
||||
}
|
||||
+46
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+161
@@ -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)
|
||||
);
|
||||
}
|
||||
+42
@@ -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 };
|
||||
}
|
||||
+45
-3
@@ -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]);
|
||||
});
|
||||
});
|
||||
+41
@@ -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",
|
||||
|
||||
+29
-2
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user