diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..4b937d5b5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -158,7 +158,7 @@ function createSnapshotForTargetUser(options: { }): OrchestrationReadModel { const messages: Array = []; - 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; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e30801041..0d03fb07d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -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, @@ -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"; @@ -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; @@ -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 + >({}); + const onToggleAllDirectories = useCallback((turnId: TurnId) => { + setAllDirectoriesExpandedByTurnId((current) => ({ + ...current, + [turnId]: !(current[turnId] ?? true), + })); + }, []); const rowVirtualizer = useVirtualizer({ count: virtualizedRowCount, @@ -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; @@ -274,15 +315,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); - const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< - Record - >({}); - const onToggleAllDirectories = useCallback((turnId: TurnId) => { - setAllDirectoriesExpandedByTurnId((current) => ({ - ...current, - [turnId]: !(current[turnId] ?? true), - })); - }, []); const renderRowContent = (row: TimelineRow) => (
, + 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, + 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); diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 73a21cd08..6d16657ef 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -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", () => { @@ -28,7 +28,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }], }), - ).toBe(346); + ).toBe(323); expect( estimateTimelineMessageHeight({ @@ -36,7 +36,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }], }), - ).toBe(346); + ).toBe(323); }); it("adds a second attachment row for three or four user attachments", () => { @@ -46,7 +46,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], }), - ).toBe(574); + ).toBe(551); expect( estimateTimelineMessageHeight({ @@ -54,7 +54,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], }), - ).toBe(574); + ).toBe(551); }); it("does not cap long user message estimates", () => { @@ -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", () => { @@ -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", () => { @@ -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", () => { @@ -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", () => { @@ -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); + }); +}); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 78a5f6539..af909ce51 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -2,7 +2,7 @@ const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; const USER_CHARS_PER_LINE_FALLBACK = 56; const LINE_HEIGHT_PX = 22; const ASSISTANT_BASE_HEIGHT_PX = 78; -const USER_BASE_HEIGHT_PX = 96; +const USER_BASE_HEIGHT_PX = 73; const ATTACHMENTS_PER_ROW = 2; // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; @@ -13,6 +13,18 @@ const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; +const USER_LONG_WRAP_BIAS_THRESHOLD_LINES = 40; +const WORK_GROUP_ROW_BOTTOM_PADDING_PX = 16; +const WORK_GROUP_CARD_VERTICAL_PADDING_PX = 14; +const WORK_GROUP_HEADER_HEIGHT_PX = 20; +const WORK_GROUP_HEADER_TO_LIST_GAP_PX = 8; +const WORK_ENTRY_HEIGHT_PX = 28; +const WORK_ENTRY_STACK_GAP_PX = 2; +const WORK_ENTRY_CHANGED_FILES_TOP_MARGIN_PX = 4; +const WORK_ENTRY_CHANGED_FILES_ROW_HEIGHT_PX = 22; +const WORK_ENTRY_CHANGED_FILE_CHIP_WIDTH_PX = 168; +const WORK_ENTRY_CHANGED_FILES_MAX_VISIBLE = 4; +const WORK_ENTRY_CHANGED_FILES_WIDTH_PADDING_PX = 96; interface TimelineMessageHeightInput { role: "user" | "assistant" | "system"; @@ -21,7 +33,19 @@ interface TimelineMessageHeightInput { } interface TimelineHeightEstimateLayout { - timelineWidthPx: number | null; + timelineWidthPx?: number | null; +} + +interface TimelineWorkGroupHeightInput { + tone: "thinking" | "tool" | "info" | "error"; + detail?: string; + command?: string; + changedFiles?: ReadonlyArray; +} + +interface TimelineWorkGroupEstimateLayout extends TimelineHeightEstimateLayout { + expanded?: boolean; + maxVisibleEntries?: number; } function estimateWrappedLineCount(text: string, charsPerLine: number): number { @@ -47,44 +71,129 @@ function isFinitePositiveNumber(value: number | null | undefined): value is numb return typeof value === "number" && Number.isFinite(value) && value > 0; } +function estimateCharsPerLine( + availableWidthPx: number | null, + averageCharWidthPx: number, + minimumCharsPerLine: number, + fallbackCharsPerLine: number, +): number { + if (!isFinitePositiveNumber(availableWidthPx)) return fallbackCharsPerLine; + return Math.max(minimumCharsPerLine, Math.floor(availableWidthPx / averageCharWidthPx)); +} + function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { - if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; - const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; - const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); - return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); + const bubbleWidthPx = isFinitePositiveNumber(timelineWidthPx) + ? timelineWidthPx * USER_BUBBLE_WIDTH_RATIO + : null; + const textWidthPx = + bubbleWidthPx === null ? null : Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); + return estimateCharsPerLine( + textWidthPx, + USER_MONO_AVG_CHAR_WIDTH_PX, + MIN_USER_CHARS_PER_LINE, + USER_CHARS_PER_LINE_FALLBACK, + ); } function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { - if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; - const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); - return Math.max( + const textWidthPx = isFinitePositiveNumber(timelineWidthPx) + ? Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0) + : null; + return estimateCharsPerLine( + textWidthPx, + ASSISTANT_AVG_CHAR_WIDTH_PX, MIN_ASSISTANT_CHARS_PER_LINE, - Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), + ASSISTANT_CHARS_PER_LINE_FALLBACK, + ); +} + +function estimateChangedFileChipRows( + changedFileCount: number, + timelineWidthPx: number | null, +): number { + if (changedFileCount <= 0) return 0; + const availableWidthPx = isFinitePositiveNumber(timelineWidthPx) + ? Math.max(timelineWidthPx - WORK_ENTRY_CHANGED_FILES_WIDTH_PADDING_PX, 0) + : WORK_ENTRY_CHANGED_FILE_CHIP_WIDTH_PX * 2; + const chipsPerRow = Math.max( + 1, + Math.floor(availableWidthPx / WORK_ENTRY_CHANGED_FILE_CHIP_WIDTH_PX), ); + return Math.ceil(Math.min(changedFileCount, WORK_ENTRY_CHANGED_FILES_MAX_VISIBLE) / chipsPerRow); +} + +function estimateWorkEntryHeight( + entry: TimelineWorkGroupHeightInput, + timelineWidthPx: number | null, +): number { + let height = WORK_ENTRY_HEIGHT_PX; + const changedFileCount = entry.changedFiles?.length ?? 0; + const previewIsChangedFiles = changedFileCount > 0 && !entry.command && !entry.detail; + if (changedFileCount > 0 && !previewIsChangedFiles) { + height += + WORK_ENTRY_CHANGED_FILES_TOP_MARGIN_PX + + estimateChangedFileChipRows(changedFileCount, timelineWidthPx) * + WORK_ENTRY_CHANGED_FILES_ROW_HEIGHT_PX; + } + return height; } export function estimateTimelineMessageHeight( message: TimelineMessageHeightInput, layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, ): number { + const timelineWidthPx = layout.timelineWidthPx ?? null; if (message.role === "assistant") { - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const charsPerLine = estimateCharsPerLineForAssistant(timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; } if (message.role === "user") { - const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + const charsPerLine = estimateCharsPerLineForUser(timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const wrapBiasLines = estimatedLines >= USER_LONG_WRAP_BIAS_THRESHOLD_LINES ? 1 : 0; 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 * LINE_HEIGHT_PX + attachmentHeight; + return ( + USER_BASE_HEIGHT_PX + (estimatedLines + wrapBiasLines) * LINE_HEIGHT_PX + attachmentHeight + ); } // `system` messages are not rendered in the chat timeline, but keep a stable // explicit branch in case they are present in timeline data. - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const charsPerLine = estimateCharsPerLineForAssistant(timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; } + +export function estimateTimelineWorkGroupHeight( + groupedEntries: ReadonlyArray, + layout: TimelineWorkGroupEstimateLayout = { timelineWidthPx: null }, +): number { + const timelineWidthPx = layout.timelineWidthPx ?? null; + if (groupedEntries.length === 0) { + return WORK_GROUP_ROW_BOTTOM_PADDING_PX + WORK_GROUP_CARD_VERTICAL_PADDING_PX; + } + + const maxVisibleEntries = layout.maxVisibleEntries ?? groupedEntries.length; + const isExpanded = layout.expanded ?? false; + const hasOverflow = groupedEntries.length > maxVisibleEntries; + const visibleEntries = + hasOverflow && !isExpanded ? groupedEntries.slice(-maxVisibleEntries) : groupedEntries; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const showHeader = hasOverflow || !onlyToolEntries; + const entryHeights = visibleEntries.reduce( + (totalHeight, entry) => totalHeight + estimateWorkEntryHeight(entry, timelineWidthPx), + 0, + ); + + return ( + WORK_GROUP_ROW_BOTTOM_PADDING_PX + + WORK_GROUP_CARD_VERTICAL_PADDING_PX + + entryHeights + + Math.max(visibleEntries.length - 1, 0) * WORK_ENTRY_STACK_GAP_PX + + (showHeader ? WORK_GROUP_HEADER_HEIGHT_PX + WORK_GROUP_HEADER_TO_LIST_GAP_PX : 0) + ); +} diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index 6389fc3ee..60929f192 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; +import { + buildTurnDiffTree, + countVisibleTurnDiffTreeNodes, + summarizeTurnDiffStats, +} from "./turnDiffTree"; describe("summarizeTurnDiffStats", () => { it("sums only files with numeric additions/deletions", () => { @@ -166,3 +170,25 @@ describe("buildTurnDiffTree", () => { expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]); }); }); + +describe("countVisibleTurnDiffTreeNodes", () => { + it("counts only top-level nodes when directories are collapsed", () => { + const tree = buildTurnDiffTree([ + { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, + { path: "apps/web/src/App.tsx", additions: 4, deletions: 2 }, + { path: "README.md", additions: 1, deletions: 0 }, + ]); + + expect(countVisibleTurnDiffTreeNodes(tree, false)).toBe(2); + }); + + it("counts nested directory and file rows when directories are expanded", () => { + const tree = buildTurnDiffTree([ + { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, + { path: "apps/web/src/App.tsx", additions: 4, deletions: 2 }, + { path: "README.md", additions: 1, deletions: 0 }, + ]); + + expect(countVisibleTurnDiffTreeNodes(tree, true)).toBe(4); + }); +}); diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index cd9bfc831..a88a8d55b 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -170,3 +170,17 @@ export function buildTurnDiffTree(files: ReadonlyArray): Tur return toTreeNodes(root); } + +export function countVisibleTurnDiffTreeNodes( + nodes: ReadonlyArray, + allDirectoriesExpanded: boolean, +): number { + let visibleNodeCount = 0; + for (const node of nodes) { + visibleNodeCount += 1; + if (node.kind === "directory" && allDirectoriesExpanded) { + visibleNodeCount += countVisibleTurnDiffTreeNodes(node.children, allDirectoriesExpanded); + } + } + return visibleNodeCount; +}