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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ function createSnapshotForTargetUser(options: {
}): OrchestrationReadModel {
const messages: Array<OrchestrationReadModel["threads"][number]["messages"][number]> = [];

for (let index = 0; index < 22; index += 1) {
for (let index = 0; index < 70; index += 1) {
const isTarget = index === 3;
const userId = `msg-user-${index}` as MessageId;
const assistantId = `msg-assistant-${index}` as MessageId;
Expand Down
118 changes: 99 additions & 19 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
import { type TurnDiffSummary } from "../../types";
import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
import {
buildTurnDiffTree,
countVisibleTurnDiffTreeNodes,
summarizeTurnDiffStats,
} from "../../lib/turnDiffTree";
import ChatMarkdown from "../ChatMarkdown";
import {
BotIcon,
Expand All @@ -26,7 +30,7 @@ import {
} from "lucide-react";
import { Button } from "../ui/button";
import { clamp } from "effect/Number";
import { estimateTimelineMessageHeight } from "../timelineHeight";
import { estimateTimelineMessageHeight, estimateTimelineWorkGroupHeight } from "../timelineHeight";
import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
import { ProposedPlanCard } from "./ProposedPlanCard";
import { ChangedFilesTree } from "./ChangedFilesTree";
Expand All @@ -39,6 +43,8 @@ import { formatTimestamp } from "../../timestampFormat";

const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
const TIMELINE_BASE_OVERSCAN_ROWS = 8;
const MIN_ROWS_TO_ENABLE_VIRTUALIZATION = 120;

interface MessagesTimelineProps {
hasMessages: boolean;
Expand Down Expand Up @@ -218,10 +224,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({
return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex);
}, [activeTurnInProgress, activeTurnStartedAt, rows]);

const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {
minimum: 0,
maximum: rows.length,
});
const shouldVirtualizeRows = rows.length > MIN_ROWS_TO_ENABLE_VIRTUALIZATION;
const virtualizedRowCount = shouldVirtualizeRows
? clamp(firstUnvirtualizedRowIndex, {
minimum: 0,
maximum: rows.length,
})
: 0;
const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
Record<string, boolean>
>({});
const onToggleAllDirectories = useCallback((turnId: TurnId) => {
setAllDirectoriesExpandedByTurnId((current) => ({
...current,
[turnId]: !(current[turnId] ?? true),
}));
}, []);

const rowVirtualizer = useVirtualizer({
count: virtualizedRowCount,
Expand All @@ -231,25 +249,48 @@ export const MessagesTimeline = memo(function MessagesTimeline({
estimateSize: (index: number) => {
const row = rows[index];
if (!row) return 96;
if (row.kind === "work") return 112;
if (row.kind === "work") {
return estimateTimelineWorkGroupHeight(row.groupedEntries, {
expanded: expandedWorkGroups[row.id] ?? false,
maxVisibleEntries: MAX_VISIBLE_WORK_LOG_ENTRIES,
timelineWidthPx,
});
}
if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan);
if (row.kind === "working") return 40;
return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
const turnDiffSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id);
return estimateTimelineRowHeight(row, {
timelineWidthPx,
turnDiffSummary,
allDirectoriesExpanded:
row.message.role === "assistant" && turnDiffSummary
? (allDirectoriesExpandedByTurnId[turnDiffSummary.turnId] ?? true)
: true,
});
},
measureElement: measureVirtualElement,
useAnimationFrameWithResizeObserver: true,
overscan: 8,
overscan: TIMELINE_BASE_OVERSCAN_ROWS,
});
useEffect(() => {
if (timelineWidthPx === null) return;
rowVirtualizer.measure();
}, [rowVirtualizer, timelineWidthPx]);
useEffect(() => {
rowVirtualizer.measure();
}, [
allDirectoriesExpandedByTurnId,
expandedWorkGroups,
rowVirtualizer,
rows,
turnDiffSummaryByAssistantMessageId,
]);
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
return remainingDistance <= AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
Expand All @@ -274,15 +315,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({

const virtualRows = rowVirtualizer.getVirtualItems();
const nonVirtualizedRows = rows.slice(virtualizedRowCount);
const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
Record<string, boolean>
>({});
const onToggleAllDirectories = useCallback((turnId: TurnId) => {
setAllDirectoriesExpandedByTurnId((current) => ({
...current,
[turnId]: !(current[turnId] ?? true),
}));
}, []);

const renderRowContent = (row: TimelineRow) => (
<div
Expand Down Expand Up @@ -604,6 +636,54 @@ function estimateTimelineProposedPlanHeight(proposedPlan: TimelineProposedPlan):
return 120 + Math.min(estimatedLines * 22, 880);
}

function estimateTimelineRowHeight(
row: Extract<TimelineRow, { kind: "message" }>,
layout: {
timelineWidthPx: number | null;
turnDiffSummary: TurnDiffSummary | undefined;
allDirectoriesExpanded: boolean;
},
): number {
let height = estimateTimelineMessageHeight(row.message, {
timelineWidthPx: layout.timelineWidthPx,
});
if (row.message.role !== "assistant") {
return height;
}

if (row.showCompletionDivider) {
height += 40;
}
if (layout.turnDiffSummary && layout.turnDiffSummary.files.length > 0) {
height += estimateChangedFilesSummaryHeight(
layout.turnDiffSummary.files,
layout.allDirectoriesExpanded,
);
}
return height;
}

function estimateChangedFilesSummaryHeight(
files: ReadonlyArray<TurnDiffSummary["files"][number]>,
allDirectoriesExpanded: boolean,
): number {
const summaryChromeHeightPx = 64;
const rowHeightPx = 22;
const rowGapPx = 2;
if (files.length <= 0) {
return 0;
}
const visibleRowCount = countVisibleTurnDiffTreeNodes(
buildTurnDiffTree(files),
allDirectoriesExpanded,
);
return (
summaryChromeHeightPx +
visibleRowCount * rowHeightPx +
Math.max(visibleRowCount - 1, 0) * rowGapPx
);
}

function formatWorkingTimer(startIso: string, endIso: string): string | null {
const startedAtMs = Date.parse(startIso);
const endedAtMs = Date.parse(endIso);
Expand Down
70 changes: 59 additions & 11 deletions apps/web/src/components/timelineHeight.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { estimateTimelineMessageHeight } from "./timelineHeight";
import { estimateTimelineMessageHeight, estimateTimelineWorkGroupHeight } from "./timelineHeight";

describe("estimateTimelineMessageHeight", () => {
it("uses assistant sizing rules for assistant messages", () => {
Expand Down Expand Up @@ -28,15 +28,15 @@ describe("estimateTimelineMessageHeight", () => {
text: "hello",
attachments: [{ id: "1" }],
}),
).toBe(346);
).toBe(323);

expect(
estimateTimelineMessageHeight({
role: "user",
text: "hello",
attachments: [{ id: "1" }, { id: "2" }],
}),
).toBe(346);
).toBe(323);
});

it("adds a second attachment row for three or four user attachments", () => {
Expand All @@ -46,15 +46,15 @@ describe("estimateTimelineMessageHeight", () => {
text: "hello",
attachments: [{ id: "1" }, { id: "2" }, { id: "3" }],
}),
).toBe(574);
).toBe(551);

expect(
estimateTimelineMessageHeight({
role: "user",
text: "hello",
attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }],
}),
).toBe(574);
).toBe(551);
});

it("does not cap long user message estimates", () => {
Expand All @@ -63,7 +63,7 @@ describe("estimateTimelineMessageHeight", () => {
role: "user",
text: "a".repeat(56 * 120),
}),
).toBe(2736);
).toBe(2735);
});

it("counts explicit newlines for user message estimates", () => {
Expand All @@ -72,7 +72,7 @@ describe("estimateTimelineMessageHeight", () => {
role: "user",
text: "first\nsecond\nthird",
}),
).toBe(162);
).toBe(139);
});

it("uses narrower width to increase user line wrapping", () => {
Expand All @@ -81,8 +81,8 @@ describe("estimateTimelineMessageHeight", () => {
text: "a".repeat(52),
};

expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140);
expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118);
expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(117);
expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(95);
});

it("does not clamp user wrapping too aggressively on very narrow layouts", () => {
Expand All @@ -91,8 +91,8 @@ describe("estimateTimelineMessageHeight", () => {
text: "a".repeat(20),
};

expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184);
expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118);
expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(161);
expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(95);
});

it("uses narrower width to increase assistant line wrapping", () => {
Expand All @@ -105,3 +105,51 @@ describe("estimateTimelineMessageHeight", () => {
expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122);
});
});

describe("estimateTimelineWorkGroupHeight", () => {
it("accounts for visible entries, header chrome, and row spacing", () => {
expect(
estimateTimelineWorkGroupHeight(
Array.from({ length: 6 }, (_, index) => ({
tone: "tool" as const,
command: `command-${index}`,
})),
{ maxVisibleEntries: 6 },
),
).toBe(208);
});

it("uses the collapsed visible-entry limit and header when the group overflows", () => {
expect(
estimateTimelineWorkGroupHeight(
Array.from({ length: 8 }, (_, index) => ({
tone: "tool" as const,
command: `command-${index}`,
})),
{ maxVisibleEntries: 6, expanded: false },
),
).toBe(236);
});

it("accounts for wrapped changed-file chips based on width", () => {
const groupedEntries = [
{
tone: "info" as const,
detail: "Updated files",
changedFiles: ["a.ts", "b.ts", "c.ts", "d.ts"],
},
];

expect(
estimateTimelineWorkGroupHeight(groupedEntries, {
timelineWidthPx: 320,
}),
).toBe(178);

expect(
estimateTimelineWorkGroupHeight(groupedEntries, {
timelineWidthPx: 768,
}),
).toBe(112);
});
});
Loading
Loading