diff --git a/AGENTS.md b/AGENTS.md index 537bdaea55c..5240d0eb518 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ ## Rebrand Note -This project was forked from T3 Code and fully rebranded to MarCode. When merging upstream changes, always check for and replace any remaining T3 references: +This project was forked from T3 Code and fully rebranded to MarCode. When merging upstream changes, always check for and replace any remaining T3 references, and **reject reintroduction of JS virtualization in `MessagesTimeline.tsx`** (see "Timeline rendering" section under Performance): - Package imports: `@marcode/contracts`, `@marcode/shared/*` (never `@t3tools`) - Env vars: `MARCODE_` prefix (never `T3CODE_`) @@ -103,6 +103,20 @@ ChatView uses **fine-grained Zustand selectors** (one per thread/project ID) ins - Its volatile dependencies (`activePendingProgress`, `activePendingUserInput`, `composerTerminalContexts`, `composerJiraTaskContexts`) are accessed via **refs** in callbacks, not in the `useCallback` dependency array. - Fallback empty arrays use **module-level constants** (`EMPTY_TERMINAL_CONTEXT_DRAFTS`, `EMPTY_JIRA_TASK_DRAFTS`) instead of inline `[]`. +### Timeline rendering: NO JS virtualization (`MessagesTimeline.tsx`) + +**CRITICAL — DO NOT REINTRODUCE `@tanstack/react-virtual` or any JS virtualizer for the messages timeline.** This has been deliberately removed twice. Upstream (T3 Code) uses `useVirtualizer` with absolute positioning + `transform: translateY()`, but it causes persistent message overlap and scroll lag in MarCode because: + +- Variable-height messages (markdown, code blocks, images, expandable diffs, quoted contexts) make height estimation fundamentally inaccurate +- Async content (Suspense code highlighting, image loads) changes height after initial measurement +- Expandable/collapsible sections (Show full diff, work groups) change height without virtualizer notification +- `ChatView.tsx` directly manipulates `scrollTop` for interaction anchoring and auto-scroll, which desynchronizes from the virtualizer's internal scroll state +- `SelectionReplyToolbar` wraps every assistant message in extra DOM, adding unmeasured height + +**Instead, we use CSS `content-visibility: auto`** with `contain-intrinsic-block-size` hints. All rows render in normal document flow — overlap is physically impossible. The browser natively skips painting offscreen content, giving equivalent performance without the positioning bugs. Height estimates in `timelineHeight.ts` feed into `containIntrinsicBlockSize` for accurate scrollbar sizing. + +When merging upstream changes that touch `MessagesTimeline.tsx`, **reject any reintroduction of `useVirtualizer`, `measureElement`, `VirtualItem`, absolute-positioned row containers, or `shouldAdjustScrollPositionOnItemSizeChange`**. Keep the `content-visibility: auto` rendering path. + ### Timeline row memoization (`MessagesTimeline.tsx`) Each timeline row renders through a `memo`'d `TimelineRowContent` component (not an inline function). When adding new row types or modifying row rendering, keep the logic inside `TimelineRowContent` to preserve per-row memoization. diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index ce29c1f9634..8e315f20448 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1211,11 +1211,9 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + const { measuredRowHeightPx, timelineWidthMeasuredPx } = await mounted.measureUserRow(targetMessageId); - expect(renderedInVirtualizedRegion).toBe(true); - const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, { timelineWidthPx: timelineWidthMeasuredPx }, @@ -1254,7 +1252,6 @@ describe("ChatView timeline estimator parity (full app)", () => { { timelineWidthPx: measurement.timelineWidthMeasuredPx }, ); - expect(measurement.renderedInVirtualizedRegion).toBe(true); expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( viewport.textTolerancePx, ); @@ -1331,11 +1328,9 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + const { measuredRowHeightPx, timelineWidthMeasuredPx } = await mounted.measureUserRow(targetMessageId); - expect(renderedInVirtualizedRegion).toBe(true); - const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 253063691b1..b5bb1888b90 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1215,8 +1215,9 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveWorkLogEntries(timelineThreadActivities, timelineLatestTurn?.turnId ?? undefined, { excludeTodoToolCalls: showTodosInComposer, + isSessionRunning: phase === "running", }), - [timelineLatestTurn?.turnId, timelineThreadActivities, showTodosInComposer], + [timelineLatestTurn?.turnId, timelineThreadActivities, showTodosInComposer, phase], ); const timelineLatestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(timelineThreadActivities, timelineLatestTurn?.turnId), @@ -4615,7 +4616,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Messages */}
+
{keyedLines.map((line, idx) => {
if (line.type === "separator") {
const hiddenCount = parseInt(line.content, 10);
return (
- ··· {hiddenCount > 0 ? `${hiddenCount} lines hidden` : "···"} ···
+ {hiddenCount > 0 ? `${hiddenCount} unmodified lines` : "···"}
);
}
@@ -258,12 +261,12 @@ export const DiffLinesBlock = memo(function DiffLinesBlock(props: DiffLinesBlock
-
+
{MARKER_CHAR[line.type]}
{highlighted ? (
@@ -305,7 +308,7 @@ export const InlineDiffPreview = memo(function InlineDiffPreview(props: { hunk:
>
- {OPERATION_LABELS[hunk.operation]}({shortenPath(hunk.filePath)})
+ {OPERATION_LABELS[hunk.operation]}({hunk.filePath})
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index e532a8ccea6..cf4ac70b25a 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -9,13 +9,10 @@ import {
useState,
type ReactNode,
} from "react";
-import {
- measureElement as measureVirtualElement,
- type VirtualItem,
- useVirtualizer,
-} from "@tanstack/react-virtual";
+// Virtualization replaced with CSS content-visibility: auto for overlap-free
+// rendering. See commit 8d4da730 for the original removal rationale.
import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
-import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
+// AUTO_SCROLL_BOTTOM_THRESHOLD_PX no longer needed without virtualizer
import { type ChatMessage, type TurnDiffSummary } from "../../types";
import { type ComposerImageAttachment } from "../../composerDraftStore";
import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
@@ -40,16 +37,16 @@ import {
} from "lucide-react";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
-import { clamp } from "effect/Number";
+import { estimateTimelineMessageHeight } from "../timelineHeight";
import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
import { ProposedPlanCard } from "./ProposedPlanCard";
import { ChangedFilesTree } from "./ChangedFilesTree";
import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
import { MessageCopyButton } from "./MessageCopyButton";
+import TextRevealContainer from "./TextReveal";
import {
MAX_VISIBLE_WORK_LOG_ENTRIES,
deriveMessagesTimelineRows,
- estimateMessagesTimelineRowHeight,
normalizeCompactToolLabel,
type MessagesTimelineRow,
} from "./MessagesTimeline.logic";
@@ -70,11 +67,7 @@ import { cn } from "~/lib/utils";
import { extractTrailingJiraContexts, type ParsedJiraContextEntry } from "~/lib/jiraContext";
import { JiraTaskInlineChip } from "./JiraTaskInlineChip";
import { SelectionReplyToolbar } from "./SelectionReplyToolbar";
-import {
- extractLeadingQuotedContexts,
- type ParsedQuotedContextEntry,
- type QuotedContext,
-} from "~/lib/quotedContext";
+import { extractLeadingQuotedContexts, type QuotedContext } from "~/lib/quotedContext";
import { UserMessageQuotedContextLabel } from "./UserMessageQuotedContextLabel";
import { type TimestampFormat } from "@marcode/contracts/settings";
import { formatTimestamp } from "../../timestampFormat";
@@ -84,7 +77,6 @@ import {
textContainsInlineTerminalContextLabels,
} from "./userMessageTerminalContexts";
-const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
const EMPTY_EDIT_IMAGES: ComposerImageAttachment[] = [];
interface MessagesTimelineProps {
@@ -140,9 +132,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
threadId,
hasMessages,
isWorking,
- activeTurnInProgress,
+ activeTurnInProgress: _activeTurnInProgress,
activeTurnStartedAt,
- scrollContainer,
+ scrollContainer: _scrollContainer,
timelineEntries,
completionDividerBeforeEntryId,
completionSummary,
@@ -172,7 +164,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onCancelEditUserMessage,
onSubmitEditUserMessage,
onReplyToSelection,
- onVirtualizerSnapshot,
+ onVirtualizerSnapshot: _onVirtualizerSnapshot,
}: MessagesTimelineProps) {
const timelineRootRef = useRef(null);
const [timelineWidthPx, setTimelineWidthPx] = useState(null);
@@ -213,138 +205,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
[timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt],
);
- const firstUnvirtualizedRowIndex = useMemo(() => {
- const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
- if (!activeTurnInProgress) return firstTailRowIndex;
-
- const turnStartedAtMs =
- typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;
- let firstCurrentTurnRowIndex = -1;
- if (!Number.isNaN(turnStartedAtMs)) {
- firstCurrentTurnRowIndex = rows.findIndex((row) => {
- if (row.kind === "working") return true;
- if (!row.createdAt) return false;
- const rowCreatedAtMs = Date.parse(row.createdAt);
- return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs;
- });
- }
-
- if (firstCurrentTurnRowIndex < 0) {
- firstCurrentTurnRowIndex = rows.findIndex(
- (row) => row.kind === "message" && row.message.streaming,
- );
- }
-
- if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex;
-
- for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) {
- const previousRow = rows[index];
- if (!previousRow || previousRow.kind !== "message") continue;
- if (previousRow.message.role === "user") {
- return Math.min(index, firstTailRowIndex);
- }
- if (previousRow.message.role === "assistant" && !previousRow.message.streaming) {
- break;
- }
- }
-
- return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex);
- }, [activeTurnInProgress, activeTurnStartedAt, rows]);
-
- const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {
- minimum: 0,
- maximum: rows.length,
- });
- const virtualMeasurementScopeKey =
- timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`;
-
- const rowVirtualizer = useVirtualizer({
- count: virtualizedRowCount,
- getScrollElement: () => scrollContainer,
- // Scope cached row measurements to the current timeline width so offscreen
- // rows do not keep stale heights after wrapping changes.
- getItemKey: (index: number) => {
- const rowId = rows[index]?.id ?? String(index);
- return `${virtualMeasurementScopeKey}:${rowId}`;
- },
- estimateSize: (index: number) => {
- const row = rows[index];
- if (!row) return 96;
- return estimateMessagesTimelineRowHeight(row, {
- expandedWorkGroups,
- timelineWidthPx,
- turnDiffSummaryByAssistantMessageId,
- });
- },
- measureElement: measureVirtualElement,
- useAnimationFrameWithResizeObserver: true,
- overscan: 8,
- });
- useEffect(() => {
- if (timelineWidthPx === null) return;
- rowVirtualizer.measure();
- }, [rowVirtualizer, timelineWidthPx]);
- useEffect(() => {
- rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
- const viewportHeight = instance.scrollRect?.height ?? 0;
- const scrollOffset = instance.scrollOffset ?? 0;
- const itemIntersectsViewport =
- item.end > scrollOffset && item.start < scrollOffset + viewportHeight;
- if (itemIntersectsViewport) {
- return false;
- }
- const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
- return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
- };
- return () => {
- rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
- };
- }, [rowVirtualizer]);
- const pendingMeasureFrameRef = useRef(null);
- const onTimelineImageLoad = useCallback(() => {
- if (pendingMeasureFrameRef.current !== null) return;
- pendingMeasureFrameRef.current = window.requestAnimationFrame(() => {
- pendingMeasureFrameRef.current = null;
- rowVirtualizer.measure();
- });
- }, [rowVirtualizer]);
- useEffect(() => {
- return () => {
- const frame = pendingMeasureFrameRef.current;
- if (frame !== null) {
- window.cancelAnimationFrame(frame);
- }
- };
- }, []);
- useLayoutEffect(() => {
- if (!onVirtualizerSnapshot) {
- return;
- }
- onVirtualizerSnapshot({
- totalSize: rowVirtualizer.getTotalSize(),
- measurements: rowVirtualizer.measurementsCache
- .slice(0, virtualizedRowCount)
- .flatMap((measurement) => {
- const row = rows[measurement.index];
- if (!row) {
- return [];
- }
- return [
- {
- id: row.id,
- kind: row.kind,
- index: measurement.index,
- size: measurement.size,
- start: measurement.start,
- end: measurement.end,
- },
- ];
- }),
- });
- }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]);
-
- const virtualRows = rowVirtualizer.getVirtualItems();
- const nonVirtualizedRows = rows.slice(virtualizedRowCount);
+ const showInlineDiffs = expandedWorkGroups;
+ const onTimelineImageLoad = useCallback(() => {}, []);
const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
Record
>({});
@@ -405,7 +267,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
);
})()}
- {row.kind === "file-change" && }
+ {row.kind === "file-change" && (
+
+ )}
{row.kind === "exploration" && }
@@ -472,17 +336,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({
)}
-
-
-
+
+
+
+
{(() => {
const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id);
if (!turnSummary) return null;
@@ -606,33 +475,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
data-timeline-root="true"
className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"
>
- {virtualizedRowCount > 0 && (
-
- {virtualRows.map((virtualRow: VirtualItem) => {
- const row = rows[virtualRow.index];
- if (!row) return null;
-
- return (
-
- {renderRowContent(row)}
-
- );
- })}
+ {rows.map((row) => (
+
+ {renderRowContent(row)}
- )}
-
- {nonVirtualizedRows.map((row) => (
- {renderRowContent(row)}
))}
);
@@ -643,6 +495,21 @@ type TimelineMessage = Extract["message"];
type TimelineWorkEntry = Extract["groupedEntries"][number];
type TimelineRow = MessagesTimelineRow;
+function estimateRowHeight(
+ row: TimelineRow,
+ _showInlineDiffs: Record,
+ timelineWidthPx: number | null,
+): number {
+ if (row.kind === "message") {
+ return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
+ }
+ if (row.kind === "proposed-plan") return 200;
+ if (row.kind === "working") return 40;
+ if (row.kind === "file-change") return 64;
+ if (row.kind === "work") return 64;
+ return 64;
+}
+
function formatWorkingTimer(startIso: string, endIso: string): string | null {
const startedAtMs = Date.parse(startIso);
const endedAtMs = Date.parse(endIso);
diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx
deleted file mode 100644
index c43f202327e..00000000000
--- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx
+++ /dev/null
@@ -1,1051 +0,0 @@
-import "../../index.css";
-
-import { MessageId, type TurnId } from "@marcode/contracts";
-import { page } from "vitest/browser";
-import { useCallback, useState, type ComponentProps } from "react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { render } from "vitest-browser-react";
-
-import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic";
-import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
-import { MessagesTimeline } from "./MessagesTimeline";
-import {
- deriveMessagesTimelineRows,
- estimateMessagesTimelineRowHeight,
-} from "./MessagesTimeline.logic";
-
-const DEFAULT_VIEWPORT = {
- width: 960,
- height: 1_100,
-};
-const MARKDOWN_CWD = "/repo/project";
-
-interface RowMeasurement {
- actualHeightPx: number;
- estimatedHeightPx: number;
- timelineWidthPx: number;
- virtualizerSizePx: number;
- renderedInVirtualizedRegion: boolean;
-}
-
-interface VirtualizationScenario {
- name: string;
- targetRowId: string;
- props: Omit, "scrollContainer">;
- maxEstimateDeltaPx: number;
-}
-
-interface VirtualizerSnapshot {
- totalSize: number;
- measurements: ReadonlyArray<{
- id: string;
- kind: string;
- index: number;
- size: number;
- start: number;
- end: number;
- }>;
-}
-
-function MessagesTimelineBrowserHarness(
- props: Omit, "scrollContainer">,
-) {
- const [scrollContainer, setScrollContainer] = useState(null);
- const [expandedWorkGroups, setExpandedWorkGroups] = useState>(
- () => props.expandedWorkGroups,
- );
- const handleToggleWorkGroup = useCallback(
- (groupId: string) => {
- setExpandedWorkGroups((current) => ({
- ...current,
- [groupId]: !(current[groupId] ?? false),
- }));
- props.onToggleWorkGroup(groupId);
- },
- [props],
- );
-
- return (
-
-
-
- );
-}
-
-function isoAt(offsetSeconds: number): string {
- return new Date(Date.UTC(2026, 2, 17, 19, 12, 28) + offsetSeconds * 1_000).toISOString();
-}
-
-function createMessage(input: {
- id: string;
- role: ChatMessage["role"];
- text: string;
- offsetSeconds: number;
- attachments?: ChatMessage["attachments"];
-}): ChatMessage {
- return {
- id: MessageId.makeUnsafe(input.id),
- role: input.role,
- text: input.text,
- ...(input.attachments ? { attachments: input.attachments } : {}),
- createdAt: isoAt(input.offsetSeconds),
- ...(input.role === "assistant" ? { completedAt: isoAt(input.offsetSeconds + 1) } : {}),
- streaming: false,
- };
-}
-
-function createToolWorkEntry(input: {
- id: string;
- offsetSeconds: number;
- label?: string;
- detail?: string;
-}): WorkLogEntry {
- return {
- id: input.id,
- createdAt: isoAt(input.offsetSeconds),
- label: input.label ?? "exec_command completed",
- ...(input.detail ? { detail: input.detail } : {}),
- tone: "tool",
- toolTitle: "exec_command",
- };
-}
-
-function createPlan(input: {
- id: string;
- offsetSeconds: number;
- planMarkdown: string;
-}): ProposedPlan {
- return {
- id: input.id as ProposedPlan["id"],
- turnId: null,
- planMarkdown: input.planMarkdown,
- implementedAt: null,
- implementationThreadId: null,
- createdAt: isoAt(input.offsetSeconds),
- updatedAt: isoAt(input.offsetSeconds + 1),
- };
-}
-
-function createBaseTimelineProps(input: {
- messages?: ChatMessage[];
- proposedPlans?: ProposedPlan[];
- workEntries?: WorkLogEntry[];
- expandedWorkGroups?: Record;
- completionDividerBeforeEntryId?: string | null;
- turnDiffSummaryByAssistantMessageId?: Map;
- onVirtualizerSnapshot?: ComponentProps["onVirtualizerSnapshot"];
-}): Omit, "scrollContainer"> {
- return {
- threadId: "test-thread",
- hasMessages: true,
- isWorking: false,
- activeTurnInProgress: false,
- activeTurnStartedAt: null,
- timelineEntries: deriveTimelineEntries(
- input.messages ?? [],
- input.proposedPlans ?? [],
- input.workEntries ?? [],
- ),
- completionDividerBeforeEntryId: input.completionDividerBeforeEntryId ?? null,
- completionSummary: null,
- turnDiffSummaryByAssistantMessageId: input.turnDiffSummaryByAssistantMessageId ?? new Map(),
- nowIso: isoAt(10_000),
- expandedWorkGroups: input.expandedWorkGroups ?? {},
- onToggleWorkGroup: () => {},
- onOpenTurnDiff: () => {},
- revertTurnCountByUserMessageId: new Map(),
- onRevertUserMessage: () => {},
- isRevertingCheckpoint: false,
- onImageExpand: () => {},
- markdownCwd: MARKDOWN_CWD,
- resolvedTheme: "light",
- timestampFormat: "locale",
- workspaceRoot: MARKDOWN_CWD,
- isSendBusy: false,
- isPreparingWorktree: false,
- onSubagentSelect: () => {},
- editingUserMessageId: null,
- editingUserMessageText: "",
- editingUserMessageImages: [],
- onStartEditUserMessage: () => {},
- onChangeEditingUserMessageText: () => {},
- onAddEditingUserMessageImages: () => {},
- onRemoveEditingUserMessageImage: () => {},
- onCancelEditUserMessage: () => {},
- onSubmitEditUserMessage: () => {},
- onReplyToSelection: () => {},
- ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}),
- };
-}
-
-function createFillerMessages(input: {
- prefix: string;
- startOffsetSeconds: number;
- pairCount: number;
-}): ChatMessage[] {
- const messages: ChatMessage[] = [];
- for (let index = 0; index < input.pairCount; index += 1) {
- const baseOffset = input.startOffsetSeconds + index * 4;
- messages.push(
- createMessage({
- id: `${input.prefix}-user-${index}`,
- role: "user",
- text: `filler user message ${index}`,
- offsetSeconds: baseOffset,
- }),
- );
- messages.push(
- createMessage({
- id: `${input.prefix}-assistant-${index}`,
- role: "assistant",
- text: `filler assistant message ${index}`,
- offsetSeconds: baseOffset + 1,
- }),
- );
- }
- return messages;
-}
-
-function createChangedFilesSummary(
- targetMessageId: MessageId,
- files: TurnDiffSummary["files"],
-): Map {
- return new Map([
- [
- targetMessageId,
- {
- turnId: "turn-changed-files" as TurnId,
- completedAt: isoAt(10),
- assistantMessageId: targetMessageId,
- files,
- },
- ],
- ]);
-}
-
-function createChangedFilesScenario(input: {
- name: string;
- rowId: string;
- files: TurnDiffSummary["files"];
- maxEstimateDeltaPx?: number;
-}): VirtualizationScenario {
- const beforeMessages = createFillerMessages({
- prefix: `${input.rowId}-before`,
- startOffsetSeconds: 0,
- pairCount: 2,
- });
- const afterMessages = createFillerMessages({
- prefix: `${input.rowId}-after`,
- startOffsetSeconds: 40,
- pairCount: 8,
- });
- const changedFilesMessage = createMessage({
- id: input.rowId,
- role: "assistant",
- text: "Validation passed on the merged tree.",
- offsetSeconds: 12,
- });
-
- return {
- name: input.name,
- targetRowId: changedFilesMessage.id,
- props: createBaseTimelineProps({
- messages: [...beforeMessages, changedFilesMessage, ...afterMessages],
- turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(
- changedFilesMessage.id,
- input.files,
- ),
- }),
- maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 72,
- };
-}
-
-function createAssistantMessageScenario(input: {
- name: string;
- rowId: string;
- text: string;
- maxEstimateDeltaPx?: number;
-}): VirtualizationScenario {
- const beforeMessages = createFillerMessages({
- prefix: `${input.rowId}-before`,
- startOffsetSeconds: 0,
- pairCount: 2,
- });
- const afterMessages = createFillerMessages({
- prefix: `${input.rowId}-after`,
- startOffsetSeconds: 40,
- pairCount: 8,
- });
- const assistantMessage = createMessage({
- id: input.rowId,
- role: "assistant",
- text: input.text,
- offsetSeconds: 12,
- });
-
- return {
- name: input.name,
- targetRowId: assistantMessage.id,
- props: createBaseTimelineProps({
- messages: [...beforeMessages, assistantMessage, ...afterMessages],
- }),
- maxEstimateDeltaPx: input.maxEstimateDeltaPx ?? 16,
- };
-}
-
-function buildStaticScenarios(): VirtualizationScenario[] {
- const beforeMessages = createFillerMessages({
- prefix: "before",
- startOffsetSeconds: 0,
- pairCount: 2,
- });
- const afterMessages = createFillerMessages({
- prefix: "after",
- startOffsetSeconds: 40,
- pairCount: 8,
- });
-
- const longUserMessage = createMessage({
- id: "target-user-long",
- role: "user",
- text: "x".repeat(3_200),
- offsetSeconds: 12,
- });
- const workEntries = Array.from({ length: 4 }, (_, index) =>
- createToolWorkEntry({
- id: `target-work-${index}`,
- offsetSeconds: 12 + index,
- detail: `tool output line ${index + 1}`,
- }),
- );
- const moderatePlan = createPlan({
- id: "target-plan",
- offsetSeconds: 12,
- planMarkdown: [
- "# Stabilize virtualization",
- "",
- "- Gather baseline measurements",
- "- Add browser harness coverage",
- "- Compare estimated and rendered heights",
- "- Fix the broken rows without broad refactors",
- "- Re-run lint and typecheck",
- ].join("\n"),
- });
- return [
- {
- name: "long user message",
- targetRowId: longUserMessage.id,
- props: createBaseTimelineProps({
- messages: [...beforeMessages, longUserMessage, ...afterMessages],
- }),
- maxEstimateDeltaPx: 56,
- },
- {
- name: "grouped work log row",
- targetRowId: workEntries[0]!.id,
- props: createBaseTimelineProps({
- messages: [...beforeMessages, ...afterMessages],
- workEntries,
- }),
- maxEstimateDeltaPx: 56,
- },
- {
- name: "expanded grouped work log row with show more enabled",
- targetRowId: "target-work-expanded-0",
- props: createBaseTimelineProps({
- messages: [...beforeMessages, ...afterMessages],
- workEntries: Array.from({ length: 10 }, (_, index) =>
- createToolWorkEntry({
- id: `target-work-expanded-${index}`,
- offsetSeconds: 12 + index,
- detail: `tool output line ${index + 1}`,
- }),
- ),
- expandedWorkGroups: {
- "target-work-expanded-0": true,
- },
- }),
- maxEstimateDeltaPx: 72,
- },
- {
- name: "proposed plan row",
- targetRowId: moderatePlan.id,
- props: createBaseTimelineProps({
- messages: [...beforeMessages, ...afterMessages],
- proposedPlans: [moderatePlan],
- }),
- maxEstimateDeltaPx: 96,
- },
- createAssistantMessageScenario({
- name: "assistant single-paragraph row with plain prose",
- rowId: "target-assistant-plain-prose",
- text: [
- "The host is still expanding to content somewhere in the grid layout.",
- "I'm stripping it back further to a plain block container so the test width",
- "is actually the timeline width.",
- ].join(" "),
- }),
- createAssistantMessageScenario({
- name: "assistant single-paragraph row with inline code",
- rowId: "target-assistant-inline-code",
- text: [
- "Typecheck found one exact-optional-property issue in the browser harness:",
- "I was always passing `onVirtualizerSnapshot`, including `undefined`.",
- "I'm tightening that object construction and rerunning the checks.",
- ].join(" "),
- maxEstimateDeltaPx: 28,
- }),
- createChangedFilesScenario({
- name: "assistant changed-files row with a compacted single-chain directory",
- rowId: "target-assistant-changed-files-single-chain",
- files: [
- { path: "apps/web/src/components/chat/ChangedFilesTree.tsx", additions: 37, deletions: 45 },
- {
- path: "apps/web/src/components/chat/ChangedFilesTree.test.tsx",
- additions: 0,
- deletions: 26,
- },
- ],
- }),
- createChangedFilesScenario({
- name: "assistant changed-files row with a branch after compaction",
- rowId: "target-assistant-changed-files-branch-point",
- files: [
- { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
- { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
- {
- path: "apps/server/src/provider/Layers/CodexAdapter.ts",
- additions: 27,
- deletions: 8,
- },
- {
- path: "apps/server/src/provider/Layers/CodexAdapter.test.ts",
- additions: 36,
- deletions: 0,
- },
- ],
- }),
- createChangedFilesScenario({
- name: "assistant changed-files row with mixed root and nested entries",
- rowId: "target-assistant-changed-files-mixed-root",
- files: [
- { path: "README.md", additions: 5, deletions: 1 },
- { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
- { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
- ],
- }),
- ];
-}
-
-async function nextFrame(): Promise {
- await new Promise((resolve) => {
- window.requestAnimationFrame(() => resolve());
- });
-}
-
-async function waitForLayout(): Promise {
- await nextFrame();
- await nextFrame();
- await nextFrame();
-}
-
-async function setViewport(viewport: { width: number; height: number }): Promise {
- await page.viewport(viewport.width, viewport.height);
- await waitForLayout();
-}
-
-async function waitForProductionStyles(): Promise {
- await vi.waitFor(
- () => {
- expect(
- getComputedStyle(document.documentElement).getPropertyValue("--background").trim(),
- ).not.toBe("");
- expect(getComputedStyle(document.body).marginTop).toBe("0px");
- },
- { timeout: 4_000, interval: 16 },
- );
-}
-
-async function waitForElement(
- query: () => T | null,
- errorMessage: string,
-): Promise {
- let element: T | null = null;
- await vi.waitFor(
- () => {
- element = query();
- expect(element, errorMessage).toBeTruthy();
- },
- { timeout: 8_000, interval: 16 },
- );
- if (!element) {
- throw new Error(errorMessage);
- }
- return element;
-}
-
-async function measureTimelineRow(input: {
- host: HTMLElement;
- props: Omit, "scrollContainer">;
- targetRowId: string;
-}): Promise {
- const scrollContainer = await waitForElement(
- () =>
- input.host.querySelector(
- '[data-testid="messages-timeline-scroll-container"]',
- ),
- "Unable to find MessagesTimeline scroll container.",
- );
-
- const rowSelector = `[data-timeline-row-id="${input.targetRowId}"]`;
- const virtualRowSelector = `[data-virtual-row-id="${input.targetRowId}"]`;
-
- let timelineWidthPx = 0;
- let actualHeightPx = 0;
- let virtualizerSizePx = 0;
- let renderedInVirtualizedRegion = false;
-
- await vi.waitFor(
- async () => {
- scrollContainer.scrollTop = 0;
- scrollContainer.dispatchEvent(new Event("scroll"));
- await waitForLayout();
-
- const rowElement = input.host.querySelector(rowSelector);
- const virtualRowElement = input.host.querySelector(virtualRowSelector);
- const timelineRoot = input.host.querySelector('[data-timeline-root="true"]');
-
- expect(rowElement, "Unable to locate target timeline row.").toBeTruthy();
- expect(virtualRowElement, "Unable to locate target virtualized wrapper.").toBeTruthy();
- expect(timelineRoot, "Unable to locate MessagesTimeline root.").toBeTruthy();
-
- timelineWidthPx = timelineRoot!.getBoundingClientRect().width;
- actualHeightPx = rowElement!.getBoundingClientRect().height;
- virtualizerSizePx = Number.parseFloat(virtualRowElement!.dataset.virtualRowSize ?? "0");
- renderedInVirtualizedRegion = virtualRowElement!.hasAttribute("data-index");
-
- expect(timelineWidthPx).toBeGreaterThan(0);
- expect(actualHeightPx).toBeGreaterThan(0);
- expect(virtualizerSizePx).toBeGreaterThan(0);
- expect(renderedInVirtualizedRegion).toBe(true);
- },
- { timeout: 8_000, interval: 16 },
- );
-
- const rows = deriveMessagesTimelineRows({
- timelineEntries: input.props.timelineEntries,
- completionDividerBeforeEntryId: input.props.completionDividerBeforeEntryId,
- isWorking: input.props.isWorking,
- activeTurnStartedAt: input.props.activeTurnStartedAt,
- });
- const targetRow = rows.find((row) => row.id === input.targetRowId);
- expect(targetRow, `Unable to derive target row ${input.targetRowId}.`).toBeTruthy();
-
- return {
- actualHeightPx,
- estimatedHeightPx: estimateMessagesTimelineRowHeight(targetRow!, {
- expandedWorkGroups: input.props.expandedWorkGroups,
- timelineWidthPx,
- turnDiffSummaryByAssistantMessageId: input.props.turnDiffSummaryByAssistantMessageId,
- }),
- timelineWidthPx,
- virtualizerSizePx,
- renderedInVirtualizedRegion,
- };
-}
-
-async function mountMessagesTimeline(input: {
- props: Omit, "scrollContainer">;
- viewport?: { width: number; height: number };
-}) {
- const viewport = input.viewport ?? DEFAULT_VIEWPORT;
- await setViewport(viewport);
- await waitForProductionStyles();
-
- const host = document.createElement("div");
- host.style.width = `${viewport.width}px`;
- host.style.minWidth = `${viewport.width}px`;
- host.style.maxWidth = `${viewport.width}px`;
- host.style.height = `${viewport.height}px`;
- host.style.minHeight = `${viewport.height}px`;
- host.style.maxHeight = `${viewport.height}px`;
- host.style.display = "block";
- host.style.overflow = "hidden";
- document.body.append(host);
-
- const screen = await render( , {
- container: host,
- });
- await waitForLayout();
-
- return {
- host,
- rerender: async (
- nextProps: Omit, "scrollContainer">,
- ) => {
- await screen.rerender( );
- await waitForLayout();
- },
- setContainerSize: async (nextViewport: { width: number; height: number }) => {
- await setViewport(nextViewport);
- host.style.width = `${nextViewport.width}px`;
- host.style.minWidth = `${nextViewport.width}px`;
- host.style.maxWidth = `${nextViewport.width}px`;
- host.style.height = `${nextViewport.height}px`;
- host.style.minHeight = `${nextViewport.height}px`;
- host.style.maxHeight = `${nextViewport.height}px`;
- await waitForLayout();
- },
- cleanup: async () => {
- await screen.unmount();
- host.remove();
- },
- };
-}
-
-async function measureRenderedRowActualHeight(input: {
- host: HTMLElement;
- targetRowId: string;
-}): Promise {
- const rowElement = await waitForElement(
- () => input.host.querySelector(`[data-timeline-row-id="${input.targetRowId}"]`),
- `Unable to locate rendered row ${input.targetRowId}.`,
- );
- return rowElement.getBoundingClientRect().height;
-}
-
-describe("MessagesTimeline virtualization harness", () => {
- beforeEach(async () => {
- document.body.innerHTML = "";
- await setViewport(DEFAULT_VIEWPORT);
- });
-
- afterEach(() => {
- document.body.innerHTML = "";
- });
-
- it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => {
- const mounted = await mountMessagesTimeline({ props: scenario.props });
-
- try {
- const measurement = await measureTimelineRow({
- host: mounted.host,
- props: scenario.props,
- targetRowId: scenario.targetRowId,
- });
-
- expect(
- Math.abs(measurement.actualHeightPx - measurement.estimatedHeightPx),
- `estimate delta for ${scenario.name}`,
- ).toBeLessThanOrEqual(scenario.maxEstimateDeltaPx);
- } finally {
- await mounted.cleanup();
- }
- });
-
- it("keeps the changed-files row virtualizer size in sync after collapsing directories", async () => {
- const beforeMessages = createFillerMessages({
- prefix: "before-collapse",
- startOffsetSeconds: 0,
- pairCount: 2,
- });
- const afterMessages = createFillerMessages({
- prefix: "after-collapse",
- startOffsetSeconds: 40,
- pairCount: 8,
- });
- const targetMessage = createMessage({
- id: "target-assistant-collapse",
- role: "assistant",
- text: "Validation passed on the merged tree.",
- offsetSeconds: 12,
- });
- const props = createBaseTimelineProps({
- messages: [...beforeMessages, targetMessage, ...afterMessages],
- turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [
- { path: ".plans/effect-atom.md", additions: 89, deletions: 0 },
- {
- path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts",
- additions: 4,
- deletions: 3,
- },
- {
- path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
- additions: 131,
- deletions: 128,
- },
- {
- path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts",
- additions: 1,
- deletions: 1,
- },
- { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 },
- {
- path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts",
- additions: 106,
- deletions: 112,
- },
- { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
- { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
- {
- path: "apps/web/src/components/chat/MessagesTimeline.tsx",
- additions: 52,
- deletions: 7,
- },
- {
- path: "apps/web/src/components/chat/ChangedFilesTree.tsx",
- additions: 32,
- deletions: 4,
- },
- { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
- { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
- ]),
- });
- const mounted = await mountMessagesTimeline({
- props,
- viewport: { width: 320, height: 700 },
- });
-
- try {
- const beforeCollapse = await measureTimelineRow({
- host: mounted.host,
- props,
- targetRowId: targetMessage.id,
- });
- const targetRowElement = mounted.host.querySelector(
- `[data-timeline-row-id="${targetMessage.id}"]`,
- );
- expect(targetRowElement, "Unable to locate target changed-files row.").toBeTruthy();
-
- const collapseAllButton =
- Array.from(targetRowElement!.querySelectorAll("button")).find(
- (button) => button.textContent?.trim() === "Collapse all",
- ) ?? null;
- expect(collapseAllButton, 'Unable to find "Collapse all" button.').toBeTruthy();
-
- collapseAllButton!.click();
-
- await vi.waitFor(
- async () => {
- const afterCollapse = await measureTimelineRow({
- host: mounted.host,
- props,
- targetRowId: targetMessage.id,
- });
- expect(afterCollapse.actualHeightPx).toBeLessThan(beforeCollapse.actualHeightPx - 24);
- },
- { timeout: 8_000, interval: 16 },
- );
-
- const afterCollapse = await measureTimelineRow({
- host: mounted.host,
- props,
- targetRowId: targetMessage.id,
- });
- expect(
- Math.abs(afterCollapse.actualHeightPx - afterCollapse.virtualizerSizePx),
- ).toBeLessThanOrEqual(8);
- } finally {
- await mounted.cleanup();
- }
- });
-
- it("keeps the work-log row virtualizer size in sync after show more expands the group", async () => {
- const beforeMessages = createFillerMessages({
- prefix: "before-worklog-expand",
- startOffsetSeconds: 0,
- pairCount: 2,
- });
- const afterMessages = createFillerMessages({
- prefix: "after-worklog-expand",
- startOffsetSeconds: 40,
- pairCount: 8,
- });
- const workEntries = Array.from({ length: 10 }, (_, index) =>
- createToolWorkEntry({
- id: `target-work-toggle-${index}`,
- offsetSeconds: 12 + index,
- detail: `tool output line ${index + 1}`,
- }),
- );
- const props = createBaseTimelineProps({
- messages: [...beforeMessages, ...afterMessages],
- workEntries,
- });
- const mounted = await mountMessagesTimeline({ props });
-
- try {
- const beforeExpand = await measureTimelineRow({
- host: mounted.host,
- props,
- targetRowId: workEntries[0]!.id,
- });
- const targetRowElement = mounted.host.querySelector(
- `[data-timeline-row-id="${workEntries[0]!.id}"]`,
- );
- expect(targetRowElement, "Unable to locate target work-log row.").toBeTruthy();
-
- const showMoreButton =
- Array.from(targetRowElement!.querySelectorAll("button")).find((button) =>
- button.textContent?.includes("Show 4 more"),
- ) ?? null;
- expect(showMoreButton, 'Unable to find "Show more" button.').toBeTruthy();
-
- showMoreButton!.click();
-
- await vi.waitFor(
- async () => {
- const afterExpand = await measureTimelineRow({
- host: mounted.host,
- props,
- targetRowId: workEntries[0]!.id,
- });
- expect(afterExpand.actualHeightPx).toBeGreaterThan(beforeExpand.actualHeightPx + 72);
- },
- { timeout: 8_000, interval: 16 },
- );
-
- const afterExpand = await measureTimelineRow({
- host: mounted.host,
- props,
- targetRowId: workEntries[0]!.id,
- });
- expect(
- Math.abs(afterExpand.actualHeightPx - afterExpand.virtualizerSizePx),
- ).toBeLessThanOrEqual(8);
- } finally {
- await mounted.cleanup();
- }
- });
-
- it("preserves measured tail row heights when rows transition into virtualization", async () => {
- const beforeMessages = createFillerMessages({
- prefix: "tail-transition-before",
- startOffsetSeconds: 0,
- pairCount: 1,
- });
- const afterMessages = createFillerMessages({
- prefix: "tail-transition-after",
- startOffsetSeconds: 40,
- pairCount: 3,
- });
- const targetMessage = createMessage({
- id: "target-tail-transition",
- role: "assistant",
- text: "Validation passed on the merged tree.",
- offsetSeconds: 12,
- });
- let latestSnapshot: VirtualizerSnapshot | null = null;
- const initialProps = createBaseTimelineProps({
- messages: [...beforeMessages, targetMessage, ...afterMessages],
- turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [
- { path: ".plans/effect-atom.md", additions: 89, deletions: 0 },
- {
- path: "apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts",
- additions: 4,
- deletions: 3,
- },
- {
- path: "apps/server/src/checkpointing/Layers/CheckpointStore.ts",
- additions: 131,
- deletions: 128,
- },
- {
- path: "apps/server/src/checkpointing/Layers/CheckpointStore.test.ts",
- additions: 1,
- deletions: 1,
- },
- { path: "apps/server/src/checkpointing/Errors.ts", additions: 1, deletions: 1 },
- {
- path: "apps/server/src/git/Layers/ClaudeTextGeneration.ts",
- additions: 106,
- deletions: 112,
- },
- { path: "apps/server/src/git/Layers/GitCore.ts", additions: 44, deletions: 38 },
- { path: "apps/server/src/git/Layers/GitCore.test.ts", additions: 18, deletions: 9 },
- {
- path: "apps/web/src/components/chat/MessagesTimeline.tsx",
- additions: 52,
- deletions: 7,
- },
- {
- path: "apps/web/src/components/chat/ChangedFilesTree.tsx",
- additions: 32,
- deletions: 4,
- },
- { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 },
- { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 },
- ]),
- onVirtualizerSnapshot: (snapshot) => {
- latestSnapshot = {
- totalSize: snapshot.totalSize,
- measurements: snapshot.measurements,
- };
- },
- });
-
- const mounted = await mountMessagesTimeline({ props: initialProps });
-
- try {
- const initiallyRenderedHeight = await measureRenderedRowActualHeight({
- host: mounted.host,
- targetRowId: targetMessage.id,
- });
-
- const appendedProps = createBaseTimelineProps({
- messages: [
- ...beforeMessages,
- targetMessage,
- ...afterMessages,
- ...createFillerMessages({
- prefix: "tail-transition-extra",
- startOffsetSeconds: 120,
- pairCount: 8,
- }),
- ],
- turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId,
- onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot,
- });
- await mounted.rerender(appendedProps);
-
- const scrollContainer = await waitForElement(
- () =>
- mounted.host.querySelector(
- '[data-testid="messages-timeline-scroll-container"]',
- ),
- "Unable to find MessagesTimeline scroll container.",
- );
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
- scrollContainer.dispatchEvent(new Event("scroll"));
- await waitForLayout();
-
- await vi.waitFor(
- () => {
- const measurement = latestSnapshot?.measurements.find(
- (entry) => entry.id === targetMessage.id,
- );
- expect(
- measurement,
- "Expected target row to transition into virtualizer cache.",
- ).toBeTruthy();
- expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual(
- 8,
- );
- },
- { timeout: 8_000, interval: 16 },
- );
- } finally {
- await mounted.cleanup();
- }
- });
-
- it("preserves measured tail image row heights when rows transition into virtualization", async () => {
- const beforeMessages = createFillerMessages({
- prefix: "tail-image-before",
- startOffsetSeconds: 0,
- pairCount: 1,
- });
- const afterMessages = createFillerMessages({
- prefix: "tail-image-after",
- startOffsetSeconds: 40,
- pairCount: 3,
- });
- const targetMessage = createMessage({
- id: "target-tail-image-transition",
- role: "user",
- text: "Here is a narrow screenshot.",
- offsetSeconds: 12,
- attachments: [
- {
- type: "image",
- id: "target-tail-image",
- name: "narrow.svg",
- mimeType: "image/svg+xml",
- sizeBytes: 512,
- previewUrl:
- "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='72'%3E%3Crect width='240' height='72' fill='%23dbeafe'/%3E%3C/svg%3E",
- },
- ],
- });
- let latestSnapshot: VirtualizerSnapshot | null = null;
- const initialProps = createBaseTimelineProps({
- messages: [...beforeMessages, targetMessage, ...afterMessages],
- onVirtualizerSnapshot: (snapshot) => {
- latestSnapshot = {
- totalSize: snapshot.totalSize,
- measurements: snapshot.measurements,
- };
- },
- });
- const mounted = await mountMessagesTimeline({ props: initialProps });
-
- try {
- await vi.waitFor(
- () => {
- const image = mounted.host.querySelector(
- `[data-timeline-row-id="${targetMessage.id}"] img`,
- );
- expect(image?.naturalHeight ?? 0).toBeGreaterThan(0);
- },
- { timeout: 8_000, interval: 16 },
- );
-
- const initiallyRenderedHeight = await measureRenderedRowActualHeight({
- host: mounted.host,
- targetRowId: targetMessage.id,
- });
- const appendedProps = createBaseTimelineProps({
- messages: [
- ...beforeMessages,
- targetMessage,
- ...afterMessages,
- ...createFillerMessages({
- prefix: "tail-image-extra",
- startOffsetSeconds: 120,
- pairCount: 8,
- }),
- ],
- onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot,
- });
- await mounted.rerender(appendedProps);
-
- const scrollContainer = await waitForElement(
- () =>
- mounted.host.querySelector(
- '[data-testid="messages-timeline-scroll-container"]',
- ),
- "Unable to find MessagesTimeline scroll container.",
- );
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
- scrollContainer.dispatchEvent(new Event("scroll"));
- await waitForLayout();
-
- await vi.waitFor(
- () => {
- const measurement = latestSnapshot?.measurements.find(
- (entry) => entry.id === targetMessage.id,
- );
- expect(
- measurement,
- "Expected target image row to transition into virtualizer cache.",
- ).toBeTruthy();
- expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual(
- 8,
- );
- },
- { timeout: 8_000, interval: 16 },
- );
- } finally {
- await mounted.cleanup();
- }
- });
-});
diff --git a/apps/web/src/components/chat/QuotedContextInlineChip.tsx b/apps/web/src/components/chat/QuotedContextInlineChip.tsx
index fb772167410..42a8d1c2646 100644
--- a/apps/web/src/components/chat/QuotedContextInlineChip.tsx
+++ b/apps/web/src/components/chat/QuotedContextInlineChip.tsx
@@ -1,4 +1,4 @@
-import { QuoteIcon, XIcon } from "lucide-react";
+import { DiffIcon, QuoteIcon, XIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import {
@@ -12,11 +12,13 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
interface QuotedContextInlineChipProps {
preview: string;
tooltipText: string;
+ isDiff?: boolean;
onRemove?: () => void;
}
export function QuotedContextInlineChip(props: QuotedContextInlineChipProps) {
- const { preview, tooltipText, onRemove } = props;
+ const { preview, tooltipText, isDiff, onRemove } = props;
+ const Icon = isDiff ? DiffIcon : QuoteIcon;
return (
@@ -25,10 +27,12 @@ export function QuotedContextInlineChip(props: QuotedContextInlineChipProps) {
-
+
{preview}
diff --git a/apps/web/src/components/chat/SelectionReplyToolbar.tsx b/apps/web/src/components/chat/SelectionReplyToolbar.tsx
index 8c010e5c58c..8c7c822e33e 100644
--- a/apps/web/src/components/chat/SelectionReplyToolbar.tsx
+++ b/apps/web/src/components/chat/SelectionReplyToolbar.tsx
@@ -22,6 +22,58 @@ interface ToolbarPosition {
const TOOLBAR_HEIGHT_PX = 32;
const TOOLBAR_GAP_PX = 6;
+type SelectionContainerCallback = (hasSelection: boolean) => void;
+const containerRegistry = new Map();
+let globalListenerAttached = false;
+
+function findRegisteredContainer(node: Node | null): HTMLElement | null {
+ let current: Node | null = node;
+ while (current) {
+ if (current instanceof HTMLElement && containerRegistry.has(current)) {
+ return current;
+ }
+ current = current.parentNode;
+ }
+ return null;
+}
+
+function handleGlobalSelectionChange() {
+ const selection = window.getSelection();
+ let matchedContainer: HTMLElement | null = null;
+
+ if (selection && !selection.isCollapsed && selection.rangeCount > 0 && selection.anchorNode) {
+ matchedContainer = findRegisteredContainer(selection.anchorNode);
+ }
+
+ for (const [container, callback] of containerRegistry) {
+ callback(container === matchedContainer);
+ }
+}
+
+function registerSelectionContainer(el: HTMLElement, callback: SelectionContainerCallback) {
+ containerRegistry.set(el, callback);
+ if (!globalListenerAttached) {
+ globalListenerAttached = true;
+ document.addEventListener("selectionchange", handleGlobalSelectionChange);
+ }
+}
+
+function unregisterSelectionContainer(el: HTMLElement) {
+ containerRegistry.delete(el);
+ if (containerRegistry.size === 0 && globalListenerAttached) {
+ globalListenerAttached = false;
+ document.removeEventListener("selectionchange", handleGlobalSelectionChange);
+ }
+}
+
+function clampRangeToContainer(range: Range, containerEl: HTMLElement): Range {
+ if (containerEl.contains(range.endContainer)) return range;
+
+ const clamped = range.cloneRange();
+ clamped.setEndAfter(containerEl.lastChild ?? containerEl);
+ return clamped;
+}
+
function getSelectionMeta(containerEl: HTMLElement): {
text: string;
startOffset: number;
@@ -35,19 +87,21 @@ function getSelectionMeta(containerEl: HTMLElement): {
const range = selection.getRangeAt(0);
if (!range || !containerEl.contains(range.startContainer)) return null;
- const text = selection.toString().trim();
+ const effective = clampRangeToContainer(range, containerEl);
+
+ const text = effective.toString().trim();
if (text.length === 0) return null;
- const codeBlock = findAncestorCodeBlock(range.startContainer, containerEl);
+ const codeBlock = findAncestorCodeBlock(effective.startContainer, containerEl);
const codeLanguage = codeBlock ? extractCodeLanguageFromBlock(codeBlock) : undefined;
const preRange = document.createRange();
preRange.selectNodeContents(containerEl);
- preRange.setEnd(range.startContainer, range.startOffset);
+ preRange.setEnd(effective.startContainer, effective.startOffset);
const startOffset = preRange.toString().length;
const endOffset = startOffset + text.length;
- const rect = range.getBoundingClientRect();
+ const rect = effective.getBoundingClientRect();
return { text, startOffset, endOffset, codeLanguage, rect };
}
@@ -75,31 +129,42 @@ export const SelectionReplyToolbar = memo(function SelectionReplyToolbar(
) {
const { messageId, turnId, containerRef, onReply } = props;
const [position, setPosition] = useState(null);
+ const positionRef = useRef(null);
const toolbarRef = useRef(null);
const { copyToClipboard, isCopied } = useCopyToClipboard();
useEffect(() => {
- const handleSelectionChange = () => {
- const container = containerRef.current;
- if (!container) {
- setPosition(null);
+ const container = containerRef.current;
+ if (!container) return;
+
+ const callback: SelectionContainerCallback = (hasSelection) => {
+ if (!hasSelection) {
+ if (positionRef.current !== null) {
+ positionRef.current = null;
+ setPosition(null);
+ }
return;
}
const meta = getSelectionMeta(container);
if (!meta) {
- setPosition(null);
+ if (positionRef.current !== null) {
+ positionRef.current = null;
+ setPosition(null);
+ }
return;
}
- setPosition({
+ const next = {
top: meta.rect.top - TOOLBAR_HEIGHT_PX - TOOLBAR_GAP_PX,
left: meta.rect.left + meta.rect.width / 2,
- });
+ };
+ positionRef.current = next;
+ setPosition(next);
};
- document.addEventListener("selectionchange", handleSelectionChange);
- return () => document.removeEventListener("selectionchange", handleSelectionChange);
+ registerSelectionContainer(container, callback);
+ return () => unregisterSelectionContainer(container);
}, [containerRef]);
const handleReply = useCallback(() => {
diff --git a/apps/web/src/components/chat/TextReveal.tsx b/apps/web/src/components/chat/TextReveal.tsx
new file mode 100644
index 00000000000..7ef99e12852
--- /dev/null
+++ b/apps/web/src/components/chat/TextReveal.tsx
@@ -0,0 +1,56 @@
+import { memo, useEffect, useRef, useState, type ReactNode } from "react";
+
+const MIN_DURATION_MS = 400;
+const MAX_DURATION_MS = 1600;
+const MS_PER_CHAR = 0.7;
+
+function computeRevealDuration(textLength: number): number {
+ return Math.min(Math.max(textLength * MS_PER_CHAR, MIN_DURATION_MS), MAX_DURATION_MS);
+}
+
+interface TextRevealContainerProps {
+ children: ReactNode;
+ isStreaming: boolean;
+ textLength: number;
+}
+
+function TextRevealContainer({ children, isStreaming, textLength }: TextRevealContainerProps) {
+ const wasStreamingRef = useRef(isStreaming);
+ const [animating, setAnimating] = useState(false);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (wasStreamingRef.current && !isStreaming && textLength > 0) {
+ setAnimating(true);
+ }
+ wasStreamingRef.current = isStreaming;
+ }, [isStreaming, textLength]);
+
+ useEffect(() => {
+ if (!animating) return;
+ const el = containerRef.current;
+ if (!el) return;
+
+ const handleEnd = () => setAnimating(false);
+ el.addEventListener("animationend", handleEnd, { once: true });
+ return () => el.removeEventListener("animationend", handleEnd);
+ }, [animating]);
+
+ const durationMs = animating ? computeRevealDuration(textLength) : undefined;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default memo(TextRevealContainer);
diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts
index d0b4814184c..e1918f3758e 100644
--- a/apps/web/src/components/timelineHeight.ts
+++ b/apps/web/src/components/timelineHeight.ts
@@ -1,6 +1,9 @@
+import { extractLeadingQuotedContexts } from "../lib/quotedContext";
import { deriveDisplayedUserMessageState } from "../lib/terminalContext";
import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
+const USER_QUOTED_CONTEXT_LABEL_HEIGHT_PX = 30;
+
const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72;
const USER_CHARS_PER_LINE_FALLBACK = 56;
const USER_LINE_HEIGHT_PX = 22.75;
@@ -83,7 +86,10 @@ export function estimateTimelineMessageHeight(
if (message.role === "user") {
const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx);
- const displayedUserMessage = deriveDisplayedUserMessageState(message.text);
+ const quotedExtracted = extractLeadingQuotedContexts(message.text);
+ const textAfterQuoted =
+ quotedExtracted.contextCount > 0 ? quotedExtracted.promptText : message.text;
+ const displayedUserMessage = deriveDisplayedUserMessageState(textAfterQuoted);
const renderedText =
displayedUserMessage.contexts.length > 0
? [
@@ -97,7 +103,14 @@ export function estimateTimelineMessageHeight(
const attachmentCount = message.attachments?.length ?? 0;
const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW);
const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX;
- return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight;
+ const quotedContextHeight =
+ quotedExtracted.contextCount > 0 ? USER_QUOTED_CONTEXT_LABEL_HEIGHT_PX : 0;
+ return (
+ USER_BASE_HEIGHT_PX +
+ estimatedLines * USER_LINE_HEIGHT_PX +
+ attachmentHeight +
+ quotedContextHeight
+ );
}
// `system` messages are not rendered in the chat timeline, but keep a stable
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 4bb3311d1dd..78c9cc85bd6 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -514,3 +514,33 @@ label:has(> select#reasoning-effort) select {
-webkit-background-clip: text;
animation: ultrathink-rainbow 10s linear infinite;
}
+
+/* Text reveal animation for completed assistant messages */
+@keyframes text-reveal-sweep {
+ from {
+ -webkit-mask-position: 0% 100%;
+ mask-position: 0% 100%;
+ }
+ to {
+ -webkit-mask-position: 0% 0%;
+ mask-position: 0% 0%;
+ }
+}
+
+.text-reveal-animating {
+ --_reveal-grad: linear-gradient(
+ to bottom,
+ black 0%,
+ black 35%,
+ rgba(0, 0, 0, 0.6) 48%,
+ rgba(0, 0, 0, 0.2) 58%,
+ transparent 68%,
+ transparent 100%
+ );
+ -webkit-mask-image: var(--_reveal-grad);
+ mask-image: var(--_reveal-grad);
+ -webkit-mask-size: 100% 300%;
+ mask-size: 100% 300%;
+ animation: text-reveal-sweep var(--text-reveal-duration, 1000ms) cubic-bezier(0.22, 0.61, 0.36, 1)
+ forwards;
+}
diff --git a/apps/web/src/lib/quotedContext.ts b/apps/web/src/lib/quotedContext.ts
index 9eeec23b8fe..7dadafeb843 100644
--- a/apps/web/src/lib/quotedContext.ts
+++ b/apps/web/src/lib/quotedContext.ts
@@ -8,6 +8,7 @@ export interface QuotedContext {
readonly codeLanguage?: string | undefined;
readonly startOffset?: number | undefined;
readonly endOffset?: number | undefined;
+ readonly filePath?: string | undefined;
}
const MAX_QUOTED_TEXT_LENGTH = 5000;
@@ -41,10 +42,15 @@ export function truncateQuotedText(text: string): { text: string; wasTruncated:
}
export function quotedContextDedupKey(context: QuotedContext): string {
- return `${context.messageId}\u0000${context.startOffset ?? ""}\u0000${context.endOffset ?? ""}`;
+ const source = context.filePath ?? context.messageId;
+ return `${source}\u0000${context.startOffset ?? ""}\u0000${context.endOffset ?? ""}`;
}
export function formatQuotedContextPreview(context: QuotedContext): string {
+ if (context.filePath) {
+ const fileName = context.filePath.split("/").pop() ?? context.filePath;
+ return fileName;
+ }
const maxPreview = 80;
const singleLine = context.text.replace(/\n/g, " ").trim();
return singleLine.length > maxPreview ? `${singleLine.slice(0, maxPreview - 1)}…` : singleLine;
@@ -70,7 +76,10 @@ function formatSingleQuotedContextBlock(context: QuotedContext): string {
const safeLang = context.codeLanguage ? sanitizeCodeLanguage(context.codeLanguage) : undefined;
const langAttr = safeLang ? ` language="${safeLang}"` : "";
const safeText = escapeQuotedContextBody(context.text);
- return `\n${safeText}\n `;
+ const sourceAttr = context.filePath
+ ? ` file_path="${context.filePath}"`
+ : ` message_id="${context.messageId}"`;
+ return `\n${safeText}\n `;
}
export function buildQuotedContextBlock(contexts: ReadonlyArray): string {
@@ -104,7 +113,13 @@ export function extractLeadingQuotedContexts(text: string): ExtractedQuotedConte
const body = blockMatch[2] ?? "";
const langMatch = attrs.match(/language="([^"]+)"/);
const language = langMatch?.[1];
- const header = language ? `Quoted code (${language})` : "Quoted text";
+ const filePathMatch = attrs.match(/file_path="([^"]+)"/);
+ const filePath = filePathMatch?.[1];
+ const header = filePath
+ ? `Quoted diff (${filePath.split("/").pop() ?? filePath})`
+ : language
+ ? `Quoted code (${language})`
+ : "Quoted text";
contexts.push({ header, body: body.trim() });
blockMatch = blockPattern.exec(leadingBlock);
}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index 1c5718ba1ad..33f2467ec1c 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -684,7 +684,7 @@ describe("deriveWorkLogEntries", () => {
}),
];
- const entries = deriveWorkLogEntries(activities, undefined);
+ const entries = deriveWorkLogEntries(activities, undefined, { isSessionRunning: true });
expect(entries).toHaveLength(1);
expect(entries[0]!.agentGroup!.tasks[0]!.status).toBe("running");
expect(entries[0]!.label).toBe("1 agent running");
@@ -1076,7 +1076,7 @@ describe("deriveWorkLogEntries", () => {
}),
];
- const entries = deriveWorkLogEntries(activities, undefined);
+ const entries = deriveWorkLogEntries(activities, undefined, { isSessionRunning: true });
expect(entries[0]!.label).toBe("2 parallel agents (1 running)");
});
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 6ec7563bfb9..d031a7eca9f 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -547,9 +547,10 @@ export function deriveTodoItems(
export function deriveWorkLogEntries(
activities: ReadonlyArray,
latestTurnId: TurnId | undefined,
- options?: { excludeTodoToolCalls?: boolean },
+ options?: { excludeTodoToolCalls?: boolean; isSessionRunning?: boolean },
): WorkLogEntry[] {
const excludeTodos = options?.excludeTodoToolCalls === true;
+ const isSessionRunning = options?.isSessionRunning ?? false;
const ordered = [...activities].toSorted(compareActivitiesByOrder);
const collabToolDataByItemId = new Map();
@@ -667,6 +668,7 @@ export function deriveWorkLogEntries(
taskGroups,
collabToolDataByItemId,
collabToolDataUnkeyed,
+ isSessionRunning,
);
if (groupEntry) entries.push(groupEntry);
}
@@ -812,6 +814,7 @@ function buildAgentTaskSummary(
group: TaskActivityGroup,
collabToolDataByItemId: ReadonlyMap,
collabToolDataUnkeyed: ReadonlyArray,
+ isSessionRunning: boolean,
): AgentTaskSummary {
const completedPayload = asRecord(group.completed?.payload);
const latestProgress = group.progressEntries.at(-1);
@@ -835,6 +838,8 @@ function buildAgentTaskSummary(
if (rawStatus === "failed") status = "failed";
else if (rawStatus === "stopped") status = "stopped";
else status = "completed";
+ } else if (!isSessionRunning) {
+ status = "completed";
}
const usageSource = completedPayload ?? latestProgressPayload;
@@ -936,11 +941,18 @@ function buildAgentGroupEntry(
taskGroups: Map,
collabToolDataByItemId: ReadonlyMap,
collabToolDataUnkeyed: ReadonlyArray,
+ isSessionRunning: boolean,
): DerivedWorkLogEntry | null {
if (taskGroups.size === 0) return null;
const tasks = [...taskGroups.entries()].map(([taskId, group]) =>
- buildAgentTaskSummary(taskId, group, collabToolDataByItemId, collabToolDataUnkeyed),
+ buildAgentTaskSummary(
+ taskId,
+ group,
+ collabToolDataByItemId,
+ collabToolDataUnkeyed,
+ isSessionRunning,
+ ),
);
const hasFailed = tasks.some((t) => t.status === "failed");
diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts
index 8f3fead5327..997055299d1 100644
--- a/apps/web/test/wsRpcHarness.ts
+++ b/apps/web/test/wsRpcHarness.ts
@@ -29,6 +29,8 @@ const STREAM_METHODS = new Set([
WS_METHODS.subscribeTerminalEvents,
WS_METHODS.subscribeServerConfig,
WS_METHODS.subscribeServerLifecycle,
+ WS_METHODS.subscribeCommandOutput,
+ WS_METHODS.subscribeJiraConnectionStatus,
]);
const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys());