diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index 5f16623f40..7d1bf54b1b 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -14,10 +14,9 @@ "them when a change wins headroom. Raising one is a deliberate decision", "that needs a reason in the pull request, not a routine edit.", "", - "There is deliberately no per-chunk limit. Parse and compile cost scales", - "with the total bytes on the boot path, not with how they are divided, so", - "splitting one boot chunk into two would satisfy a per-chunk cap while", - "making the page slightly slower for the extra request.", + "The workspace checkout display chunk has a separate limit because it was", + "the shared owner of the prompt, Markdown, and diff renderers. The route", + "package check keeps those features behind their dynamic boundaries.", "", "forbiddenBootPackages is the more important half. These packages are all", "large and all needed only after a user action (open a diff, focus the", @@ -27,6 +26,7 @@ ], "maxBootBytes": 1707047, "maxBootBrotliBytes": 445742, + "maxWorkspaceCheckoutDisplayChunkBytes": 512000, "forbiddenBootPackages": [ "@pierre/diffs", "@pierre/theming", @@ -48,5 +48,26 @@ "prosemirror-view", "rehype-katex", "shiki" + ], + "forbiddenWorkspaceRoutePackages": [ + "@pierre/diffs", + "@pierre/theming", + "@shikijs/core", + "@shikijs/engine-javascript", + "@shikijs/engine-oniguruma", + "@shikijs/langs", + "@shikijs/vscode-textmate", + "@tiptap/core", + "@tiptap/pm", + "@tiptap/react", + "@tiptap/starter-kit", + "cytoscape", + "katex", + "oniguruma-to-es", + "prosemirror-model", + "prosemirror-state", + "prosemirror-view", + "rehype-katex", + "shiki" ] } diff --git a/apps/app/scripts/check-bundle-budget.mjs b/apps/app/scripts/check-bundle-budget.mjs index 4db231feb4..4f2fa076bb 100644 --- a/apps/app/scripts/check-bundle-budget.mjs +++ b/apps/app/scripts/check-bundle-budget.mjs @@ -32,54 +32,104 @@ const budget = JSON.parse(fs.readFileSync(budgetPath, "utf8")); const kb = (n) => `${(n / 1024).toFixed(1)} KB`; const failures = []; +const minPrecompressBytes = 1024; -// A boot chunk with no .br file would otherwise weigh zero against the -// compressed budget, so an unrun precompression step could hide real growth. -// Treat it as an error rather than guessing a size. -const missingBrotli = []; -let bootBytes = 0; -let bootBrotliBytes = 0; -for (const chunk of stats.bootChunks) { - bootBytes += chunk.bytes; - const brotliPath = path.join(distDir, `${chunk.fileName}.br`); - if (fs.existsSync(brotliPath)) { - bootBrotliBytes += fs.statSync(brotliPath).size; - } else { - missingBrotli.push(chunk.fileName); +// A chunk with no .br file would otherwise weigh zero against the compressed +// budget. The precompressor deliberately leaves files below 1 KB untouched, +// so count their full size and fail only when a larger compressed file is gone. +const missingBrotli = new Set(); +const measureChunks = (chunks) => { + let bytes = 0; + let brotliBytes = 0; + for (const chunk of chunks) { + bytes += chunk.bytes; + const brotliPath = path.join(distDir, `${chunk.fileName}.br`); + if (fs.existsSync(brotliPath)) { + brotliBytes += fs.statSync(brotliPath).size; + } else if (chunk.bytes < minPrecompressBytes) { + brotliBytes += chunk.bytes; + } else { + missingBrotli.add(chunk.fileName); + } } -} + return { bytes, brotliBytes }; +}; -const forbidden = new Set(budget.forbiddenBootPackages); -const offenders = new Map(); -for (const chunk of stats.bootChunks) { - for (const pkg of chunk.packages) { - if (!forbidden.has(pkg)) continue; - if (!offenders.has(pkg)) offenders.set(pkg, []); - offenders.get(pkg).push(chunk.fileName); +const bootPayload = measureChunks(stats.bootChunks); +const workspaceRoutePayload = measureChunks(stats.workspaceRouteChunks); + +const findForbiddenPackages = (chunks, forbiddenPackages) => { + const forbidden = new Set(forbiddenPackages); + const offenders = new Map(); + for (const chunk of chunks) { + for (const pkg of chunk.packages) { + if (!forbidden.has(pkg)) continue; + if (!offenders.has(pkg)) offenders.set(pkg, []); + offenders.get(pkg).push(chunk.fileName); + } } -} + return offenders; +}; + +const bootOffenders = findForbiddenPackages( + stats.bootChunks, + budget.forbiddenBootPackages, +); +const workspaceRouteOffenders = findForbiddenPackages( + stats.workspaceRouteChunks, + budget.forbiddenWorkspaceRoutePackages, +); -console.log(`boot payload: ${kb(bootBytes)} raw / ${kb(bootBrotliBytes)} brotli`); -console.log(` budget: ${kb(budget.maxBootBytes)} raw / ${kb(budget.maxBootBrotliBytes)} brotli`); +console.log( + `boot payload: ${kb(bootPayload.bytes)} raw / ${kb(bootPayload.brotliBytes)} brotli`, +); +console.log( + ` budget: ${kb(budget.maxBootBytes)} raw / ${kb(budget.maxBootBrotliBytes)} brotli`, +); console.log(` chunks: ${stats.bootChunks.length}`); +console.log( + `workspace route: ${kb(workspaceRoutePayload.bytes)} raw / ${kb(workspaceRoutePayload.brotliBytes)} brotli`, +); +console.log(` chunks: ${stats.workspaceRouteChunks.length}`); +console.log( + `checkout chunk: ${kb(stats.workspaceCheckoutDisplayChunk.bytes)} raw`, +); +console.log( + ` budget: ${kb(budget.maxWorkspaceCheckoutDisplayChunkBytes)} raw`, +); -if (missingBrotli.length > 0) { +if (missingBrotli.size > 0) { + failures.push( + `${missingBrotli.size} measured chunk(s) have no .br file, so the compressed total is understated: ${[...missingBrotli].join(", ")}. Run scripts/precompress-app-dist.mjs.`, + ); +} +if (bootPayload.bytes > budget.maxBootBytes) { failures.push( - `${missingBrotli.length} boot chunk(s) have no .br file, so the compressed total is understated: ${missingBrotli.join(", ")}. Run scripts/precompress-app-dist.mjs.`, + `boot payload is ${kb(bootPayload.bytes)}, over the ${kb(budget.maxBootBytes)} raw budget by ${kb(bootPayload.bytes - budget.maxBootBytes)}.`, ); } -if (bootBytes > budget.maxBootBytes) { +if (bootPayload.brotliBytes > budget.maxBootBrotliBytes) { failures.push( - `boot payload is ${kb(bootBytes)}, over the ${kb(budget.maxBootBytes)} raw budget by ${kb(bootBytes - budget.maxBootBytes)}.`, + `boot payload is ${kb(bootPayload.brotliBytes)} brotli, over the ${kb(budget.maxBootBrotliBytes)} budget by ${kb(bootPayload.brotliBytes - budget.maxBootBrotliBytes)}.`, ); } -if (bootBrotliBytes > budget.maxBootBrotliBytes) { +if ( + stats.workspaceCheckoutDisplayChunk.bytes > + budget.maxWorkspaceCheckoutDisplayChunkBytes +) { failures.push( - `boot payload is ${kb(bootBrotliBytes)} brotli, over the ${kb(budget.maxBootBrotliBytes)} budget by ${kb(bootBrotliBytes - budget.maxBootBrotliBytes)}.`, + `workspace checkout display chunk is ${kb(stats.workspaceCheckoutDisplayChunk.bytes)}, over the ${kb(budget.maxWorkspaceCheckoutDisplayChunkBytes)} raw budget.`, ); } -for (const [pkg, chunks] of offenders) { - failures.push(`${pkg} is in the boot payload (${chunks.join(", ")}). It must load on demand.`); +for (const [pkg, chunks] of bootOffenders) { + failures.push( + `${pkg} is in the boot payload (${chunks.join(", ")}). It must load on demand.`, + ); +} +for (const [pkg, chunks] of workspaceRouteOffenders) { + failures.push( + `${pkg} is in the workspace route preload (${chunks.join(", ")}). It must load on demand.`, + ); } if (failures.length > 0) { diff --git a/apps/app/src/components/git-diff/GitDiffCard.tsx b/apps/app/src/components/git-diff/GitDiffCard.tsx index a85d01af57..966718c9bd 100644 --- a/apps/app/src/components/git-diff/GitDiffCard.tsx +++ b/apps/app/src/components/git-diff/GitDiffCard.tsx @@ -28,13 +28,7 @@ export type { RequestDiffFileContents, } from "./GitDiffCardBody"; -export const GIT_DIFF_VIEW_BASE_OPTIONS = { - overflow: "scroll", - disableFileHeader: false, - // Reveal 30 unchanged lines per expand-up / expand-down click. Library - // default is 100 — too aggressive for our compact diff cards. - expansionLineCount: 30, -} as const; +export { GIT_DIFF_VIEW_BASE_OPTIONS } from "./git-diff-options"; export interface GitDiffCardProps { fileDiff: ParsedGitDiffFile; diff --git a/apps/app/src/components/git-diff/GitDiffCardBody.tsx b/apps/app/src/components/git-diff/GitDiffCardBody.tsx index 890e1719c3..b1f9179f80 100644 --- a/apps/app/src/components/git-diff/GitDiffCardBody.tsx +++ b/apps/app/src/components/git-diff/GitDiffCardBody.tsx @@ -1,6 +1,8 @@ import { type CSSProperties, type RefCallback, + lazy, + Suspense, useCallback, useEffect, useMemo, @@ -13,7 +15,6 @@ import type { SelectedLineRange, SelectionSide, } from "@pierre/diffs"; -import { FileDiff as DiffView } from "@pierre/diffs/react"; import { useIntersectionObserver } from "usehooks-ts"; import { Button } from "@bb/shared-ui/button"; import { usePierreLineSelectionActions } from "./PierreLineSelectionActions.js"; @@ -33,6 +34,12 @@ import { type ParsedGitDiffFile, } from "./git-diff-parsing"; +const LazyPierreDiffView = lazy(() => + import("./PierreDiffView").then((module) => ({ + default: module.PierreDiffView, + })), +); + /** * One side of a diff file resolved for the card. `text` carries UTF-8 contents * for `@pierre/diffs` context expansion; `image` carries a data URL the card @@ -1174,11 +1181,13 @@ function GitDiffCardRawDiffBody({ onPointerUpCapture={lineSelectionActions.onPointerUpCapture} >
| - # - | - {columns.map((column) => ( -- - {column.label || `Column ${column.index + 1}`} - - | - ))} -
|---|---|
| - {rowIndex + 2} - | - {columns.map((column) => { - const cell = row[column.index] ?? ""; - return ( -- - {cell} - - | - ); - })} -
- {truncationNote} -
- )} - while its worker highlighter is
- // still initializing, and the imperative instance does not always recover
- // when the highlighted AST is cached later. Wait for readiness, then remount
- // once the cache entry for this exact file appears so syntax highlighting
- // replaces the plain-text fallback.
- const workerHighlightCacheState =
- workerPool?.getFileResultCache(file) !== undefined
- ? "highlighted"
- : "plain";
-
- useEffect(() => {
- const cleanupContainer = containerRef.current;
- let animationFrame: number | null = null;
- let attempts = 0;
-
- // Retry on the next frame (the target line may not be in the DOM yet). One
- // rAF channel only: `scrollToLine` overwrites `animationFrame` on each
- // reschedule, so at most one callback is ever pending and cleanup cancels
- // it — no doubling or leaked stale callbacks marking the wrong line.
- function scheduleRetry() {
- animationFrame = window.requestAnimationFrame(scrollToLine);
- }
-
- function scrollToLine() {
- const container = containerRef.current;
- if (!container) return;
- clearPreviewTargetLine(container);
- clearPreviewTargetLine(container.ownerDocument.body);
- if (targetLineNumber === null) return;
-
- const line =
- findPreviewTargetLine(container, targetLineNumber) ??
- findPreviewTargetLine(container.ownerDocument.body, targetLineNumber);
- if (line) {
- line.setAttribute("data-file-preview-target-line", "");
- line.setAttribute("data-selected-line", "single");
- line.scrollIntoView?.({ block: "center" });
- return;
- }
-
- attempts += 1;
- if (attempts < 8) {
- scheduleRetry();
- }
- }
-
- scrollToLine();
- return () => {
- if (cleanupContainer) {
- clearPreviewTargetLine(cleanupContainer);
- clearPreviewTargetLine(cleanupContainer.ownerDocument.body);
- }
- if (animationFrame !== null) {
- window.cancelAnimationFrame(animationFrame);
- }
- };
- }, [file.contents, file.name, targetLineNumber]);
-
- if (shouldWaitForWorkerPool) {
- return ;
- }
-
- return (
-
-
- {lineSelectionActions.menu}
-
+
+
);
}
+
+export type {
+ FilePreviewFile,
+ FilePreviewHeaderMode,
+ FilePreviewProps,
+ FilePreviewState,
+ IframeFilePreviewTarget,
+ IframePreviewSandbox,
+ TextFilePreviewKind,
+} from "./FilePreviewImpl";
diff --git a/apps/app/src/components/secondary-panel/FilePreviewImpl.tsx b/apps/app/src/components/secondary-panel/FilePreviewImpl.tsx
new file mode 100644
index 0000000000..5c31f17aec
--- /dev/null
+++ b/apps/app/src/components/secondary-panel/FilePreviewImpl.tsx
@@ -0,0 +1,1350 @@
+import {
+ type CSSProperties,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { File as PierreFile, useWorkerPool } from "@pierre/diffs/react";
+import type { FileOptions } from "@pierre/diffs/react";
+import type { SelectedLineRange, SupportedLanguages } from "@pierre/diffs";
+import type { UrlTransform } from "react-markdown";
+import { Button } from "@bb/shared-ui/button";
+import { usePierreLineSelectionActions } from "@/components/git-diff/PierreLineSelectionActions.js";
+import { COARSE_POINTER_TEXT_SM_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
+import { EmptyStatePanel } from "@bb/shared-ui/empty-state";
+import { CopyButton } from "@/components/ui/copy-button.js";
+import { Icon } from "@bb/shared-ui/icon";
+import { OpenInEditorButton } from "@/components/ui/open-in-editor-button.js";
+import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider";
+import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint";
+import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js";
+import { MarkdownPreview } from "@/components/ui/markdown-preview.js";
+import { Skeleton } from "@bb/shared-ui/skeleton";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@bb/shared-ui/tooltip";
+import { TruncateStart } from "@/components/ui/truncate-start.js";
+import { usePreferredTheme } from "@/hooks/useTheme";
+import { copyToClipboardWithToast } from "@/lib/clipboard";
+import type {
+ FilePreviewLineRange,
+ WorkspaceFilePreviewStatusLabel,
+} from "@/lib/file-preview";
+import {
+ DEFAULT_CODE_OVERFLOW_MODE,
+ type CodeOverflowMode,
+ type CodeOverflowModeChangeHandler,
+} from "@/lib/code-overflow-mode";
+import { cn } from "@bb/shared-ui/lib/utils";
+import { SecondaryPanelSelectionActions } from "./SecondaryPanelSelectionActions.js";
+
+export interface FilePreviewFile {
+ cacheKey?: string;
+ name: string;
+ contents: string;
+ lang?: SupportedLanguages;
+}
+
+export type IframePreviewSandbox = "allow-scripts";
+
+export interface IframeFilePreviewTarget {
+ sandbox: IframePreviewSandbox | null;
+ title: string;
+ url: string;
+}
+
+export type FilePreviewState =
+ | { kind: "loading" }
+ | { kind: "empty" }
+ | { kind: "not-found" }
+ | { kind: "error"; message?: string }
+ | { kind: "image"; url: string }
+ | { kind: "video"; url: string }
+ | ({ kind: "iframe" } & IframeFilePreviewTarget)
+ | {
+ kind: "html";
+ file: FilePreviewFile;
+ iframe: IframeFilePreviewTarget;
+ lineRange: FilePreviewLineRange | null;
+ }
+ | {
+ kind: "ready";
+ file: FilePreviewFile;
+ lineRange: FilePreviewLineRange | null;
+ textPreviewKind: TextFilePreviewKind | null;
+ markdownUrlTransform?: UrlTransform;
+ };
+
+export interface FilePreviewProps {
+ state: FilePreviewState;
+ path: string;
+ copyPath?: string | null;
+ headerMode?: FilePreviewHeaderMode;
+ onSelectionAddToChat?: (text: string) => void;
+ onOpenInEditor?: (path: string) => void;
+ onRefresh?: () => void;
+ isRefreshing?: boolean;
+ markdownLinkRouting?: MarkdownLinkRouting;
+ statusLabel?: WorkspaceFilePreviewStatusLabel | null;
+}
+
+interface FilePreviewBodyProps {
+ state: FilePreviewState;
+ path: string;
+ lineOverflowMode: CodeOverflowMode;
+ viewMode: FilePreviewViewMode;
+ markdownLinkRouting?: MarkdownLinkRouting;
+ onSelectionAddToChat?: (text: string) => void;
+}
+
+interface HtmlFilePreviewBodyProps {
+ lineOverflowMode: CodeOverflowMode;
+ onSelectionAddToChat?: (text: string) => void;
+ state: Extract;
+ viewMode: FilePreviewViewMode;
+}
+
+interface FilePreviewHeaderProps {
+ path: string;
+ copyPath: string | null;
+ rawContents: string | null;
+ onOpenInEditor?: (path: string) => void;
+ onRefresh?: () => void;
+ isRefreshing: boolean;
+ statusLabel: WorkspaceFilePreviewStatusLabel | null;
+ toggleKind: FilePreviewToggleKind | null;
+ showLineOverflowToggle: boolean;
+ lineOverflowMode: CodeOverflowMode;
+ onLineOverflowModeChange: CodeOverflowModeChangeHandler;
+ viewMode: FilePreviewViewMode;
+ onViewModeChange: (mode: FilePreviewViewMode) => void;
+}
+
+interface FilePreviewLineWrapButtonProps {
+ showLineOverflowToggle: boolean;
+ lineOverflowMode: CodeOverflowMode;
+ onLineOverflowModeChange: CodeOverflowModeChangeHandler;
+}
+
+interface FilePreviewPathProps {
+ path: string;
+ copyPath: string | null;
+}
+
+interface MarkdownFilePreviewProps {
+ file: FilePreviewFile;
+ onSelectionAddToChat?: (text: string) => void;
+ urlTransform?: UrlTransform;
+ markdownLinkRouting?: MarkdownLinkRouting;
+}
+
+interface CsvFilePreviewProps {
+ file: FilePreviewFile;
+ onSelectionAddToChat?: (text: string) => void;
+}
+
+interface FilePreviewImageProps {
+ url: string;
+ alt: string;
+}
+
+interface FilePreviewVideoProps {
+ url: string;
+ title: string;
+}
+
+interface FilePreviewMessageProps {
+ message: string;
+ role?: "alert";
+}
+
+interface FilePreviewCodeProps {
+ file: FilePreviewFile;
+ lineOverflowMode: CodeOverflowMode;
+ lineRange: FilePreviewLineRange | null;
+ onSelectionAddToChat?: (text: string) => void;
+ path: string;
+}
+
+interface FilePreviewWorkerPoolStats {
+ managerState: "waiting" | "initializing" | "initialized";
+ workersFailed: boolean;
+ totalWorkers: number;
+ busyWorkers: number;
+ queuedTasks: number;
+ activeTasks: number;
+ themeSubscribers: number;
+ fileCacheSize: number;
+ diffCacheSize: number;
+}
+
+interface GetInitialFilePreviewViewModeArgs {
+ lineRange: FilePreviewLineRange | null;
+ toggleKind: FilePreviewToggleKind | null;
+}
+
+interface CsvPreviewData {
+ columnCount: number;
+ rows: string[][];
+ truncatedColumns: boolean;
+ truncatedRows: boolean;
+}
+
+type FilePreviewViewMode = "preview" | "source";
+export type TextFilePreviewKind = "csv" | "markdown";
+type FilePreviewToggleKind = "csv" | "html" | "markdown";
+export type FilePreviewHeaderMode = "file" | "none";
+type IframeLoadState = "loading" | "loaded" | "error";
+
+const CSV_PREVIEW_MAX_COLUMNS = 100;
+const CSV_PREVIEW_MAX_ROWS = 500;
+
+const FILE_PREVIEW_VIEW_STYLE = {
+ "--diffs-font-size": "12px",
+ "--diffs-line-height": "18px",
+ // Pierre paints its theme bg inside this gap, so the top breathing room of
+ // the code body lives on Pierre's bg — not on the panel's bg-background.
+ // Without this, the gap above Pierre would show a visible bg-color seam.
+ "--diffs-gap-block": "16px",
+} as CSSProperties;
+
+// `--md-content-w` tells MarkdownPreview the surrounding text-column width so
+// narrow tables sit flush with the prose on the left instead of centering in
+// the panel. `100cqi` resolves against the `@container/page` scope on the
+// wrapper below — i.e. the panel width.
+const FILE_PREVIEW_WRAPPER_STYLE = {
+ "--md-content-w": "100cqi",
+} as CSSProperties;
+
+const HTML_FILE_PREVIEW_IFRAME_STYLE = {
+ width: "100%",
+ height: "100%",
+ border: 0,
+} as CSSProperties;
+const IFRAME_LOADING_INDICATOR_DELAY_MS = 160;
+const FILE_PREVIEW_HEADER_ICON_BUTTON_CLASS =
+ "h-5 w-5 rounded-sm p-0 [&_svg]:size-3 max-md:pointer-coarse:h-9 max-md:pointer-coarse:w-9 max-md:pointer-coarse:[&_svg]:size-5";
+
+function getFilePreviewToggleKind(
+ state: FilePreviewState,
+): FilePreviewToggleKind | null {
+ if (state.kind === "html") {
+ return "html";
+ }
+ if (state.kind === "ready") {
+ return state.textPreviewKind;
+ }
+ return null;
+}
+
+function getToggleAriaLabel(kind: FilePreviewToggleKind): string {
+ switch (kind) {
+ case "csv":
+ return "CSV view mode";
+ case "html":
+ return "HTML view mode";
+ case "markdown":
+ return "Markdown view mode";
+ }
+}
+
+function getFileContentsCopyLabel(kind: FilePreviewToggleKind | null): string {
+ if (kind === "csv") {
+ return "Copy CSV";
+ }
+ if (kind === "markdown") {
+ return "Copy markdown";
+ }
+ if (kind === "html") {
+ return "Copy HTML source";
+ }
+ return "Copy file contents";
+}
+
+function getLineWrapToggleLabel(lineOverflowMode: CodeOverflowMode): string {
+ return lineOverflowMode === "wrap" ? "Disable line wrap" : "Wrap lines";
+}
+
+function getFilePreviewLineRange(
+ state: FilePreviewState,
+): FilePreviewLineRange | null {
+ if (state.kind === "html" || state.kind === "ready") {
+ return state.lineRange;
+ }
+ return null;
+}
+
+function getRawFilePreviewContents(state: FilePreviewState): string | null {
+ if (state.kind === "html" || state.kind === "ready") {
+ return state.file.contents;
+ }
+ return null;
+}
+
+function getInitialFilePreviewViewMode({
+ lineRange,
+ toggleKind,
+}: GetInitialFilePreviewViewModeArgs): FilePreviewViewMode {
+ if (toggleKind === "csv" || toggleKind === "markdown") {
+ return "preview";
+ }
+ return lineRange === null ? "preview" : "source";
+}
+
+function usesCodeViewLayout(
+ state: FilePreviewState,
+ viewMode: FilePreviewViewMode,
+): boolean {
+ if (state.kind === "html") {
+ return viewMode === "source";
+ }
+
+ if (state.kind !== "ready") {
+ return false;
+ }
+
+ return state.textPreviewKind === null || viewMode === "source";
+}
+
+interface ParsedCsvRows {
+ rows: string[][];
+ truncatedRows: boolean;
+}
+
+// Stops scanning once `maxRows` rows are collected, so a multi-megabyte CSV
+// only pays for the previewed prefix.
+function parseCsvRows(contents: string, maxRows: number): ParsedCsvRows {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let field = "";
+ let inQuotes = false;
+ let quotedField = false;
+ let endedWithLineBreak = false;
+
+ for (let index = 0; index < contents.length; index += 1) {
+ const character = contents[index];
+ endedWithLineBreak = false;
+
+ if (inQuotes) {
+ if (character === '"') {
+ if (contents[index + 1] === '"') {
+ field += '"';
+ index += 1;
+ } else {
+ inQuotes = false;
+ }
+ } else {
+ field += character;
+ }
+ continue;
+ }
+
+ if (character === '"' && field.length === 0) {
+ inQuotes = true;
+ quotedField = true;
+ continue;
+ }
+
+ if (character === ",") {
+ row.push(field);
+ field = "";
+ quotedField = false;
+ continue;
+ }
+
+ if (character === "\n" || character === "\r") {
+ row.push(field);
+ rows.push(row);
+ row = [];
+ field = "";
+ quotedField = false;
+ endedWithLineBreak = true;
+ if (character === "\r" && contents[index + 1] === "\n") {
+ index += 1;
+ }
+ if (rows.length >= maxRows) {
+ return { rows, truncatedRows: index + 1 < contents.length };
+ }
+ continue;
+ }
+
+ field += character;
+ }
+
+ if (
+ field.length > 0 ||
+ row.length > 0 ||
+ quotedField ||
+ !endedWithLineBreak
+ ) {
+ row.push(field);
+ rows.push(row);
+ }
+
+ return { rows, truncatedRows: false };
+}
+
+export function buildCsvPreviewData(contents: string): CsvPreviewData {
+ // +1: the first parsed row is the header, so the cap counts data rows.
+ const { rows, truncatedRows } = parseCsvRows(
+ contents,
+ CSV_PREVIEW_MAX_ROWS + 1,
+ );
+ // Column stats only consider the previewed rows; a wider row past the row
+ // cap won't flag truncatedColumns. Fine for a preview.
+ const columnCount = rows.reduce(
+ (maximum, row) => Math.max(maximum, row.length),
+ 0,
+ );
+
+ return {
+ columnCount: Math.min(columnCount, CSV_PREVIEW_MAX_COLUMNS),
+ rows,
+ truncatedColumns: columnCount > CSV_PREVIEW_MAX_COLUMNS,
+ truncatedRows,
+ };
+}
+
+export function getCsvTruncationNote(
+ preview: CsvPreviewData,
+ dataRowCount: number,
+): string | null {
+ const limits: string[] = [];
+ if (preview.truncatedRows) {
+ limits.push(`${dataRowCount.toLocaleString()} rows`);
+ }
+ if (preview.truncatedColumns) {
+ limits.push(`${preview.columnCount.toLocaleString()} columns`);
+ }
+ if (limits.length === 0) {
+ return null;
+ }
+ return `Showing the first ${limits.join(" and ")}.`;
+}
+
+export function FilePreview({
+ state,
+ path,
+ copyPath = null,
+ headerMode = "file",
+ onSelectionAddToChat,
+ onOpenInEditor,
+ onRefresh,
+ isRefreshing = false,
+ markdownLinkRouting,
+ statusLabel = null,
+}: FilePreviewProps) {
+ const toggleKind = getFilePreviewToggleKind(state);
+ const filePreviewLineRange = getFilePreviewLineRange(state);
+ const rawContents = getRawFilePreviewContents(state);
+ const [viewMode, setViewMode] = useState(
+ getInitialFilePreviewViewMode({
+ lineRange: filePreviewLineRange,
+ toggleKind,
+ }),
+ );
+ const [lineOverflowMode, setLineOverflowMode] = useState(
+ DEFAULT_CODE_OVERFLOW_MODE,
+ );
+ // Each new file opens in the appropriate default mode; the user re-toggles
+ // per file rather than carrying their last choice across unrelated files.
+ useEffect(() => {
+ setViewMode(
+ getInitialFilePreviewViewMode({
+ lineRange: filePreviewLineRange,
+ toggleKind,
+ }),
+ );
+ }, [filePreviewLineRange, path, toggleKind]);
+
+ const usesIframeLayout =
+ state.kind === "iframe" ||
+ (state.kind === "html" && viewMode === "preview");
+ const bodyViewMode: FilePreviewViewMode =
+ toggleKind === null ? "preview" : viewMode;
+ const usesCodeLayout = usesCodeViewLayout(state, bodyViewMode);
+ const showLineOverflowToggle = usesCodeLayout;
+ // The markdown preview renders on a raised "paper" surface that should fill
+ // the panel to the bottom even for short documents. `min-h-full` (vs the
+ // iframe layout's `h-full min-h-0`) keeps the column growable, so long
+ // documents still scroll the outer panel rather than an inner box.
+ const usesMarkdownPreviewLayout =
+ state.kind === "ready" &&
+ state.textPreviewKind === "markdown" &&
+ bodyViewMode === "preview";
+ // The CSV table needs one scroller that owns both axes: its sticky header
+ // row and row-number gutter only stick against their own scrollport, and
+ // splitting the axes (panel scrolls vertically, inner box horizontally)
+ // strands the horizontal scrollbar at the bottom of the full-height table
+ // and lets the sticky gutter paint over the panel header. So fill the panel
+ // like the iframe layout and let CsvFilePreview scroll internally.
+ const usesCsvPreviewLayout =
+ state.kind === "ready" &&
+ state.textPreviewKind === "csv" &&
+ bodyViewMode === "preview";
+ const usesFullHeightLayout = usesIframeLayout || usesCsvPreviewLayout;
+ const usesContentHeightLayout = usesCodeLayout || usesMarkdownPreviewLayout;
+
+ // Establish a `@container/page` scope so MarkdownPreview's `100cqw`-based
+ // table breakout sizes against this panel, not the viewport.
+ return (
+
+ {headerMode === "file" ? (
+
+ ) : null}
+
+
+ );
+}
+
+function FilePreviewBody({
+ state,
+ path,
+ lineOverflowMode,
+ viewMode,
+ markdownLinkRouting,
+ onSelectionAddToChat,
+}: FilePreviewBodyProps) {
+ if (state.kind === "loading") {
+ return ;
+ }
+ if (state.kind === "empty") {
+ return ;
+ }
+ if (state.kind === "not-found") {
+ return ;
+ }
+ if (state.kind === "error") {
+ return (
+
+ );
+ }
+ if (state.kind === "image") {
+ return ;
+ }
+ if (state.kind === "video") {
+ return ;
+ }
+ if (state.kind === "iframe") {
+ return (
+
+ );
+ }
+ if (state.kind === "html") {
+ return (
+
+ );
+ }
+ if (state.textPreviewKind === "csv" && viewMode === "preview") {
+ return (
+
+ );
+ }
+ if (state.textPreviewKind === "markdown" && viewMode === "preview") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+function FilePreviewHeader({
+ path,
+ copyPath,
+ rawContents,
+ onOpenInEditor,
+ onRefresh,
+ isRefreshing,
+ statusLabel,
+ toggleKind,
+ showLineOverflowToggle,
+ lineOverflowMode,
+ onLineOverflowModeChange,
+ viewMode,
+ onViewModeChange,
+}: FilePreviewHeaderProps) {
+ const openShortcut = useAppCommandShortcut("workspace.openPreferred");
+ const showHeaderControls = showLineOverflowToggle || toggleKind !== null;
+ const copyFileContentsLabel = getFileContentsCopyLabel(toggleKind);
+
+ return (
+ // The wrapper carries an opaque panel-surface base so the translucent
+ // `bg-surface-recessed` tint on the bar composites to a solid tone — without
+ // it, body content scrolling under the sticky header would bleed through.
+
+
+
+
+
+ {statusLabel === null ? null : (
+
+ ({statusLabel})
+
+ )}
+
+ {onRefresh ? (
+
+
+
+
+
+ {isRefreshing ? "Refreshing file" : "Refresh file"}
+
+
+ ) : null}
+ {rawContents === null ? null : (
+
+
+
+
+
+ {copyFileContentsLabel}
+
+
+ )}
+ {onOpenInEditor ? (
+ <>
+
+
+ onOpenInEditor(path)}
+ label={
+ openShortcut
+ ? `Open in editor (${openShortcut.label})`
+ : "Open in editor"
+ }
+ aria-keyshortcuts={openShortcut?.ariaKeyshortcuts}
+ />
+
+
+ {openShortcut
+ ? `Open in editor (${openShortcut.label})`
+ : "Open in editor"}
+
+
+
+ >
+ ) : null}
+
+
+ {showHeaderControls ? (
+
+
+ {toggleKind !== null ? (
+
+
+
+
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
+
+function FilePreviewPath({ path, copyPath }: FilePreviewPathProps) {
+ const copyTarget = copyPath ?? path;
+ const label = "Copy file path";
+ const className = cn(
+ "min-w-0 font-mono font-medium leading-5 text-file-accent",
+ COARSE_POINTER_TEXT_SM_CLASS,
+ );
+
+ return (
+
+
+
+
+
+ {label}
+
+
+ );
+}
+
+function FilePreviewLineWrapButton({
+ showLineOverflowToggle,
+ lineOverflowMode,
+ onLineOverflowModeChange,
+}: FilePreviewLineWrapButtonProps) {
+ if (!showLineOverflowToggle) {
+ return null;
+ }
+
+ const label = getLineWrapToggleLabel(lineOverflowMode);
+
+ return (
+
+
+
+
+
+ {label}
+
+
+ );
+}
+
+function HtmlFilePreviewBody({
+ lineOverflowMode,
+ onSelectionAddToChat,
+ state,
+ viewMode,
+}: HtmlFilePreviewBodyProps) {
+ const isPreviewVisible = viewMode === "preview";
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+function MarkdownFilePreview({
+ file,
+ onSelectionAddToChat,
+ urlTransform,
+ markdownLinkRouting,
+}: MarkdownFilePreviewProps) {
+ return (
+ // Keep rendered Markdown on the ordinary document background. Its parent
+ // owns the boundary, so another raised "paper" layer would make nested
+ // file viewers feel like cards stacked inside cards.
+
+
+
+
+
+ );
+}
+
+function CsvFilePreview({ file, onSelectionAddToChat }: CsvFilePreviewProps) {
+ const preview = useMemo(
+ () => buildCsvPreviewData(file.contents),
+ [file.contents],
+ );
+ const headerRow = preview.rows[0] ?? [];
+ const bodyRows = preview.rows.slice(1);
+ const columns = Array.from({ length: preview.columnCount }, (_, index) => ({
+ index,
+ label: headerRow[index] ?? "",
+ }));
+ const tableWidth = `max(100%, ${3 + columns.length * 18}rem)`;
+ const truncationNote = getCsvTruncationNote(preview, bodyRows.length);
+
+ return (
+
+ {/* Single scroll container for both axes: the sticky header row and
+ row-number gutter stick against this box, the horizontal scrollbar
+ stays visible at the panel bottom, and the sticky cells are clipped
+ here so they can't paint over the panel header. */}
+
+ {/* overscroll-contain: panning a wide table past its edge must not
+ chain into the browser back/forward gesture (kept alive globally —
+ see app.css overscroll notes) or scroll an ancestor. */}
+
+
+
+
+ {columns.map((column) => (
+
+ ))}
+
+
+
+
+ #
+
+ {columns.map((column) => (
+
+
+ {column.label || `Column ${column.index + 1}`}
+
+
+ ))}
+
+
+
+ {bodyRows.map((row, rowIndex) => (
+
+
+ {rowIndex + 2}
+
+ {columns.map((column) => {
+ const cell = row[column.index] ?? "";
+ return (
+
+
+ {cell}
+
+
+ );
+ })}
+
+ ))}
+
+
+
+ {truncationNote === null ? null : (
+
+ {truncationNote}
+
+ )}
+
+
+ );
+}
+
+function FilePreviewImage({ url, alt }: FilePreviewImageProps) {
+ return (
+
+
+
+ );
+}
+
+function FilePreviewVideo({ url, title }: FilePreviewVideoProps) {
+ return (
+
+
+
+ );
+}
+
+function IframeFilePreview({ sandbox, title, url }: IframeFilePreviewTarget) {
+ const [loadState, setLoadState] = useState("loading");
+ const [showLoadingIndicator, setShowLoadingIndicator] = useState(false);
+
+ useEffect(() => {
+ setLoadState("loading");
+ }, [url]);
+
+ useEffect(() => {
+ if (loadState !== "loading") {
+ setShowLoadingIndicator(false);
+ return;
+ }
+
+ setShowLoadingIndicator(false);
+ const timeoutId = window.setTimeout(() => {
+ setShowLoadingIndicator(true);
+ }, IFRAME_LOADING_INDICATOR_DELAY_MS);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [loadState, url]);
+
+ if (loadState === "error") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {loadState === "loading" && showLoadingIndicator ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+function clearPreviewTargetLine(container: HTMLElement) {
+ const targetLines = container.querySelectorAll(
+ "[data-file-preview-target-line]",
+ );
+ for (const targetLine of targetLines) {
+ targetLine.removeAttribute("data-file-preview-target-line");
+ targetLine.removeAttribute("data-selected-line");
+ }
+}
+
+function findPreviewTargetLine(
+ container: HTMLElement,
+ lineNumber: number,
+): HTMLElement | null {
+ const lines = container.querySelectorAll(`[data-line="${lineNumber}"]`);
+ for (const line of lines) {
+ if (line instanceof HTMLElement && line.dataset.lineIndex !== undefined) {
+ return line;
+ }
+ }
+ for (const line of lines) {
+ if (line instanceof HTMLElement) {
+ return line;
+ }
+ }
+ return null;
+}
+
+function formatLineRange(startLineNumber: number, endLineNumber: number) {
+ return startLineNumber === endLineNumber
+ ? String(startLineNumber)
+ : `${startLineNumber}-${endLineNumber}`;
+}
+
+function buildFilePreviewLineSelectionText({
+ contents,
+ path,
+ range,
+}: {
+ contents: string;
+ path: string;
+ range: SelectedLineRange;
+}): string | null {
+ const startLineNumber = Math.max(1, Math.min(range.start, range.end));
+ const endLineNumber = Math.max(
+ startLineNumber,
+ Math.max(range.start, range.end),
+ );
+ const lines = contents.split(/\r\n|\n|\r/);
+ const selectedLines = lines.slice(startLineNumber - 1, endLineNumber);
+ if (selectedLines.length === 0) {
+ return null;
+ }
+ const selectedText = selectedLines.join("\n").trimEnd();
+ if (selectedText.trim().length === 0) {
+ return null;
+ }
+ return `${path}:${formatLineRange(startLineNumber, endLineNumber)}\n${selectedText}`;
+}
+
+function FilePreviewLoading() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+function FilePreviewMessage({ message, role }: FilePreviewMessageProps) {
+ return (
+
+ {message}
+
+ );
+}
+
+function FilePreviewCode({
+ file,
+ lineOverflowMode,
+ lineRange,
+ onSelectionAddToChat,
+ path,
+}: FilePreviewCodeProps) {
+ const preferredTheme = usePreferredTheme();
+ const containerRef = useRef(null);
+ const workerPool = useWorkerPool();
+ const lastWorkerPoolStatsKeyRef = useRef(null);
+ const [workerPoolStats, setWorkerPoolStats] =
+ useState(null);
+ const [, rerenderAfterWorkerPoolChange] = useState(0);
+ const buildSelectionText = useCallback(
+ (range: SelectedLineRange) =>
+ buildFilePreviewLineSelectionText({
+ contents: file.contents,
+ path,
+ range,
+ }),
+ [file.contents, path],
+ );
+ const lineSelectionActions = usePierreLineSelectionActions({
+ buildSelectionText,
+ containerRef,
+ enabled: onSelectionAddToChat !== undefined,
+ onSelectionAddToChat,
+ });
+ const options = useMemo>(
+ () => ({
+ themeType: preferredTheme,
+ overflow: lineOverflowMode,
+ disableFileHeader: true,
+ enableGutterUtility: onSelectionAddToChat !== undefined,
+ enableLineSelection:
+ lineRange !== null || onSelectionAddToChat !== undefined,
+ lineHoverHighlight:
+ onSelectionAddToChat === undefined ? "disabled" : "number",
+ onGutterUtilityClick:
+ onSelectionAddToChat === undefined
+ ? undefined
+ : lineSelectionActions.onGutterUtilityClick,
+ onLineSelectionChange: lineSelectionActions.onLineSelectionChange,
+ onLineSelectionEnd: lineSelectionActions.onLineSelectionEnd,
+ onLineSelectionStart: lineSelectionActions.onLineSelectionStart,
+ }),
+ [
+ lineOverflowMode,
+ lineRange,
+ lineSelectionActions.onGutterUtilityClick,
+ lineSelectionActions.onLineSelectionChange,
+ lineSelectionActions.onLineSelectionEnd,
+ lineSelectionActions.onLineSelectionStart,
+ onSelectionAddToChat,
+ preferredTheme,
+ ],
+ );
+ const selectedLines = useMemo(() => {
+ if (lineSelectionActions.selectedRange !== null) {
+ return lineSelectionActions.selectedRange;
+ }
+ return lineRange === null
+ ? null
+ : {
+ start: lineRange.startLineNumber,
+ end: lineRange.endLineNumber,
+ };
+ }, [lineRange, lineSelectionActions.selectedRange]);
+ const targetLineNumber = selectedLines?.start ?? null;
+
+ useEffect(() => {
+ if (!workerPool) {
+ setWorkerPoolStats(null);
+ return;
+ }
+
+ lastWorkerPoolStatsKeyRef.current = null;
+ return workerPool.subscribeToStatChanges((stats) => {
+ setWorkerPoolStats(stats);
+ const statsKey = [
+ stats.managerState,
+ stats.workersFailed,
+ stats.busyWorkers,
+ stats.queuedTasks,
+ stats.activeTasks,
+ stats.fileCacheSize,
+ ].join(":");
+ if (lastWorkerPoolStatsKeyRef.current === statsKey) {
+ return;
+ }
+ lastWorkerPoolStatsKeyRef.current = statsKey;
+ rerenderAfterWorkerPoolChange((version) => version + 1);
+ });
+ }, [file.contents, file.name, workerPool]);
+
+ const shouldWaitForWorkerPool =
+ workerPool !== undefined &&
+ workerPoolStats?.managerState !== "initialized" &&
+ workerPoolStats?.workersFailed !== true;
+ // Pierre can mount an empty zero-height while its worker highlighter is
+ // still initializing, and the imperative instance does not always recover
+ // when the highlighted AST is cached later. Wait for readiness, then remount
+ // once the cache entry for this exact file appears so syntax highlighting
+ // replaces the plain-text fallback.
+ const workerHighlightCacheState =
+ workerPool?.getFileResultCache(file) !== undefined
+ ? "highlighted"
+ : "plain";
+
+ useEffect(() => {
+ const cleanupContainer = containerRef.current;
+ let animationFrame: number | null = null;
+ let attempts = 0;
+
+ // Retry on the next frame (the target line may not be in the DOM yet). One
+ // rAF channel only: `scrollToLine` overwrites `animationFrame` on each
+ // reschedule, so at most one callback is ever pending and cleanup cancels
+ // it — no doubling or leaked stale callbacks marking the wrong line.
+ function scheduleRetry() {
+ animationFrame = window.requestAnimationFrame(scrollToLine);
+ }
+
+ function scrollToLine() {
+ const container = containerRef.current;
+ if (!container) return;
+ clearPreviewTargetLine(container);
+ clearPreviewTargetLine(container.ownerDocument.body);
+ if (targetLineNumber === null) return;
+
+ const line =
+ findPreviewTargetLine(container, targetLineNumber) ??
+ findPreviewTargetLine(container.ownerDocument.body, targetLineNumber);
+ if (line) {
+ line.setAttribute("data-file-preview-target-line", "");
+ line.setAttribute("data-selected-line", "single");
+ line.scrollIntoView?.({ block: "center" });
+ return;
+ }
+
+ attempts += 1;
+ if (attempts < 8) {
+ scheduleRetry();
+ }
+ }
+
+ scrollToLine();
+ return () => {
+ if (cleanupContainer) {
+ clearPreviewTargetLine(cleanupContainer);
+ clearPreviewTargetLine(cleanupContainer.ownerDocument.body);
+ }
+ if (animationFrame !== null) {
+ window.cancelAnimationFrame(animationFrame);
+ }
+ };
+ }, [file.contents, file.name, targetLineNumber]);
+
+ if (shouldWaitForWorkerPool) {
+ return ;
+ }
+
+ return (
+
+
+ {lineSelectionActions.menu}
+
+ );
+}
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
index 9026627ccc..95b0f40384 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
@@ -37,7 +37,7 @@ import type {
SecondaryPanelTabReorderHandler,
} from "./secondaryPanelFileTab";
import { type ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel";
-import { GIT_DIFF_VIEW_BASE_OPTIONS } from "../git-diff/GitDiffCard";
+import { GIT_DIFF_VIEW_BASE_OPTIONS } from "../git-diff/git-diff-options";
import { usePreferredTheme } from "@/hooks/useTheme";
import { useEnvironmentDiffFiles } from "@/hooks/queries/environment-queries";
import {
@@ -82,17 +82,20 @@ import type { AppShortcutPresentation } from "@/lib/app-keybindings";
import { TabPill } from "@/components/ui/tab-pill";
import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip";
import { dispatchBrowserViewBoundsSync } from "@/lib/browser-view-bounds-sync";
+import {
+ THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT,
+ THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT,
+} from "./threadSecondaryPanelLayout";
+export {
+ THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT,
+ THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT,
+} from "./threadSecondaryPanelLayout";
export type {
GitDiffDisplayMode,
GitDiffSelectionOption,
} from "./GitDiffToolbar";
export type { SecondaryPanelFileTab } from "./secondaryPanelFileTab";
-// Shared with the split-workspace host's empty-state panel, which must resize
-// within the same bounds as the real panel it stands in for.
-export const THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT = 24;
-export const THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT = 70;
-
export function isSecondaryPanelLayoutTransition(
propertyName: string,
): boolean {
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
index 0ae44f9d40..952a52e7a5 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, type ReactNode } from "react";
+import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import type { WorkspaceDiffTarget } from "@bb/domain";
import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js";
@@ -23,7 +23,6 @@ import type {
WorkspaceFilePreviewStatusLabel,
} from "@/lib/file-preview";
import { cn } from "@bb/shared-ui/lib/utils";
-import { DiffFilesPanel } from "./git-diff/DiffFilesPanel";
import { clearDiffFileCardStates } from "./git-diff/diffFilesStore";
import { buildGitDiffIdentity } from "./git-diff/gitDiffPanelHelpers";
import { useDiffFileContentsRequester } from "./git-diff/useDiffFileContentsRequester";
@@ -33,6 +32,12 @@ import {
ThreadStorageFilePreview,
} from "./ThreadStorageFilePreview";
+const LazyDiffFilesPanel = lazy(() =>
+ import("./git-diff/DiffFilesPanel").then((module) => ({
+ default: module.DiffFilesPanel,
+ })),
+);
+
const GIT_DIFF_SKELETON_FILE_COUNT = 3;
const PANEL_SCROLL_SLOT_CLASS =
"min-h-0 flex-1 overflow-x-auto overflow-y-auto";
@@ -277,23 +282,25 @@ export function GitDiffTabContent({
}
return (
-
+ }>
+
+
);
}
diff --git a/apps/app/src/components/secondary-panel/threadSecondaryPanelLayout.ts b/apps/app/src/components/secondary-panel/threadSecondaryPanelLayout.ts
new file mode 100644
index 0000000000..275c070270
--- /dev/null
+++ b/apps/app/src/components/secondary-panel/threadSecondaryPanelLayout.ts
@@ -0,0 +1,4 @@
+// The split-workspace empty-state panel uses the same resize bounds as the
+// full secondary panel.
+export const THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT = 24;
+export const THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT = 70;
diff --git a/apps/app/src/components/thread/timeline/LazyThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/LazyThreadTimelineRows.tsx
new file mode 100644
index 0000000000..48ca6c5e1d
--- /dev/null
+++ b/apps/app/src/components/thread/timeline/LazyThreadTimelineRows.tsx
@@ -0,0 +1,23 @@
+import { lazy, Suspense } from "react";
+import type { ThreadTimelineRowsProps } from "./ThreadTimelineRows.js";
+
+const ThreadTimelineRowsImpl = lazy(() =>
+ import("./ThreadTimelineRows.js").then((module) => ({
+ default: module.ThreadTimelineRows,
+ })),
+);
+
+export function ThreadTimelineRows(props: ThreadTimelineRowsProps) {
+ return (
+
+
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
index c7f2bf4d81..c777f8b792 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx
@@ -13,7 +13,7 @@ import { Icon } from "@bb/shared-ui/icon";
import { Skeleton } from "@bb/shared-ui/skeleton";
import { usePreferredTheme } from "@/hooks/useTheme";
import { toUserAttachmentImageSrc } from "@/lib/user-attachment-images";
-import { ThreadTimelineRows } from "./ThreadTimelineRows.js";
+import { ThreadTimelineRows } from "./LazyThreadTimelineRows.js";
import { useAutoLoadOlderRows } from "./useAutoLoadOlderRows.js";
import { TimelineStatusIndicator } from "./TimelineStatusIndicator.js";
import type { TimelineTitleActionResolver } from "./TimelineTitleView.js";
diff --git a/apps/app/src/components/thread/timeline/index.ts b/apps/app/src/components/thread/timeline/index.ts
index d5837c6e1d..e5fce74048 100644
--- a/apps/app/src/components/thread/timeline/index.ts
+++ b/apps/app/src/components/thread/timeline/index.ts
@@ -1,5 +1,5 @@
export { isRunningThreadRuntimeDisplayStatus } from "./thread-runtime-status.js";
-export { ThreadTimelineRows } from "./ThreadTimelineRows.js";
+export { ThreadTimelineRows } from "./LazyThreadTimelineRows.js";
export type { ThreadTimelineRowsProps } from "./ThreadTimelineRows.js";
export {
ThreadTimelinePanelContent,
diff --git a/apps/app/src/views/PluginPanelView.tsx b/apps/app/src/views/PluginPanelView.tsx
index a47d8bfbbc..93d2fd7f92 100644
--- a/apps/app/src/views/PluginPanelView.tsx
+++ b/apps/app/src/views/PluginPanelView.tsx
@@ -1,24 +1,14 @@
import { useParams } from "react-router-dom";
-import { WorkerPoolContextProvider } from "@pierre/diffs/react";
import { PageShell } from "@/components/ui/page-shell.js";
import { EmptyStatePanel } from "@bb/shared-ui/empty-state";
import { PluginSlotMount } from "@/components/plugin/PluginSlotMount";
-import {
- createDiffWorker,
- getDiffWorkerPoolSize,
-} from "@/lib/diff-worker-pool";
import { usePluginSlots } from "@/lib/plugin-slots";
+import { ThreadDetailWorkerPoolProvider } from "./thread-detail/ThreadDetailWorkerPoolProvider";
// Plugins can render `@pierre/diffs` FileDiff (the specifier is shimmed to
// the host's copy); syntax highlighting needs a worker pool in React context.
// Thread panes get theirs from the split workspace — standalone nav panels get
// one here.
-const WORKER_POOL_OPTIONS = {
- workerFactory: createDiffWorker,
- poolSize: getDiffWorkerPoolSize(),
-};
-const HIGHLIGHTER_OPTIONS = {};
-
/**
* The route surface for plugin `navPanel` slots (plugin design §5.2):
* /plugins/:pluginId/:panelPath renders the matching registered panel
@@ -80,17 +70,9 @@ export function PluginPanelView(props: PluginPanelViewProps = {}) {
);
// The provider spawns workers eagerly; environments without Worker
// (jsdom tests) just render diffs unhighlighted.
- const mount =
- typeof Worker === "undefined" ? (
- slotMount
- ) : (
-
- {slotMount}
-
- );
+ const mount = (
+ {slotMount}
+ );
// Full-bleed: the negative margins undo the app layout's `p-4 md:p-5`
// route padding. Plugins opt into their own padding and scrolling.
diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx
index 8eb24e0022..d42ef7075b 100644
--- a/apps/app/src/views/RootComposeView.tsx
+++ b/apps/app/src/views/RootComposeView.tsx
@@ -7,7 +7,6 @@ import {
type ReactNode,
} from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
-import { WorkerPoolContextProvider } from "@pierre/diffs/react";
import {
findLocalPathProjectSourceForHost,
type EnvironmentStatus,
@@ -233,10 +232,7 @@ import {
resolveEnvironmentOpenContext,
resolveThreadWorkspacePreviewRootPath,
} from "./thread-detail/threadWorkspaceOpenPath";
-import {
- createDiffWorker,
- getDiffWorkerPoolSize,
-} from "@/lib/diff-worker-pool";
+import { ThreadDetailWorkerPoolProvider } from "./thread-detail/ThreadDetailWorkerPoolProvider";
import {
useAppCommandHandler,
useAppCommandShortcut,
@@ -269,12 +265,6 @@ const ROOT_COMPOSE_EMPTY_WELCOME_CONTENT_CLASS =
"min-h-full flex-1 items-center justify-center pb-12";
const ROOT_COMPOSE_FIXED_PANEL_STATE_ID = "root-compose";
const EMPTY_TERMINAL_SESSIONS: readonly TerminalSession[] = [];
-const FILE_PREVIEW_WORKER_POOL_OPTIONS = {
- workerFactory: createDiffWorker,
- poolSize: getDiffWorkerPoolSize(),
-};
-const FILE_PREVIEW_HIGHLIGHTER_OPTIONS = {};
-
type ProjectSelectionChangeHandler = NewThreadProjectConfig["onChange"];
type SecondaryPanelChangeHandler = (panel: ThreadSecondaryPanelTab) => void;
type NullableSecondaryPanelChangeHandler = (
@@ -787,12 +777,9 @@ export function RootComposeRoute() {
}
return (
-
+
-
+
);
}
diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.stories.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.stories.test.tsx
index 3b6e90372a..1c792c7173 100644
--- a/apps/app/src/views/thread-detail/SplitThreadArea.stories.test.tsx
+++ b/apps/app/src/views/thread-detail/SplitThreadArea.stories.test.tsx
@@ -12,6 +12,8 @@ describe("SplitThreadArea stories", () => {
it.each(["light", "dark"] as const)(
"renders two populated panes and follows focus in the %s theme",
async (theme) => {
+ await import("@/components/thread/timeline/ThreadTimelineRows");
+
const view = render(
@@ -42,12 +44,12 @@ describe("SplitThreadArea stories", () => {
expect(view.getByText("Fix Thread Drag Sync")).toBeTruthy();
expect(view.getByText("Refine split styling")).toBeTruthy();
expect(
- view.getByText(
+ await view.findByText(
"When I drag threads between sections, the source row sometimes stays faded after the drop.",
),
).toBeTruthy();
expect(
- view.getByText(
+ await view.findByText(
"Make the divider thinner, keep the inactive timeline readable, and let the header carry focus.",
),
).toBeTruthy();
diff --git a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx
index 86f13dc856..f3bf784865 100644
--- a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx
+++ b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx
@@ -27,7 +27,7 @@ import { secondaryPanelWidthPercentAtom } from "@/components/secondary-panel/thr
import {
THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT,
THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT,
-} from "@/components/secondary-panel/ThreadSecondaryPanel";
+} from "@/components/secondary-panel/threadSecondaryPanelLayout";
import {
SecondaryPanelHostLayoutContext,
type SecondaryPanelHostLayout,
diff --git a/apps/app/src/views/thread-detail/ThreadDetailWorkerPoolProvider.tsx b/apps/app/src/views/thread-detail/ThreadDetailWorkerPoolProvider.tsx
index 1eb3d35c8a..64b29cd166 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailWorkerPoolProvider.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailWorkerPoolProvider.tsx
@@ -1,15 +1,10 @@
-import { WorkerPoolContextProvider } from "@pierre/diffs/react";
-import type { ReactNode } from "react";
-import {
- createDiffWorker,
- getDiffWorkerPoolSize,
-} from "@/lib/diff-worker-pool";
+import { lazy, Suspense, type ReactNode } from "react";
-const WORKER_POOL_OPTIONS = {
- workerFactory: createDiffWorker,
- poolSize: getDiffWorkerPoolSize(),
-};
-const HIGHLIGHTER_OPTIONS = {};
+const WorkerPoolProviderImpl = lazy(() =>
+ import("./ThreadDetailWorkerPoolProviderImpl").then((module) => ({
+ default: module.ThreadDetailWorkerPoolProvider,
+ })),
+);
export function ThreadDetailWorkerPoolProvider({
children,
@@ -20,11 +15,8 @@ export function ThreadDetailWorkerPoolProvider({
return children;
}
return (
-
- {children}
-
+
+ {children}
+
);
}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailWorkerPoolProviderImpl.tsx b/apps/app/src/views/thread-detail/ThreadDetailWorkerPoolProviderImpl.tsx
new file mode 100644
index 0000000000..1eb3d35c8a
--- /dev/null
+++ b/apps/app/src/views/thread-detail/ThreadDetailWorkerPoolProviderImpl.tsx
@@ -0,0 +1,30 @@
+import { WorkerPoolContextProvider } from "@pierre/diffs/react";
+import type { ReactNode } from "react";
+import {
+ createDiffWorker,
+ getDiffWorkerPoolSize,
+} from "@/lib/diff-worker-pool";
+
+const WORKER_POOL_OPTIONS = {
+ workerFactory: createDiffWorker,
+ poolSize: getDiffWorkerPoolSize(),
+};
+const HIGHLIGHTER_OPTIONS = {};
+
+export function ThreadDetailWorkerPoolProvider({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ if (typeof Worker === "undefined") {
+ return children;
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/app/vite-bundle-stats.ts b/apps/app/vite-bundle-stats.ts
index 5949b08715..ba3e226653 100644
--- a/apps/app/vite-bundle-stats.ts
+++ b/apps/app/vite-bundle-stats.ts
@@ -15,6 +15,9 @@ export interface BundleBootChunk {
export interface BundleStats {
entry: string;
bootChunks: BundleBootChunk[];
+ workspaceRouteEntry: string;
+ workspaceRouteChunks: BundleBootChunk[];
+ workspaceCheckoutDisplayChunk: BundleBootChunk;
}
/**
@@ -35,33 +38,86 @@ export function bundleStats(): Plugin {
);
if (entry === undefined || entry.type !== "chunk") return;
- const bootFileNames = new Set();
- const walk = (fileName: string): void => {
- if (bootFileNames.has(fileName)) return;
- bootFileNames.add(fileName);
- const chunk = bundle[fileName];
- if (chunk === undefined || chunk.type !== "chunk") return;
- for (const imported of chunk.imports) walk(imported);
+ const collectStaticChunkClosure = (rootFileName: string): Set => {
+ const fileNames = new Set();
+ const walk = (fileName: string): void => {
+ if (fileNames.has(fileName)) return;
+ fileNames.add(fileName);
+ const chunk = bundle[fileName];
+ if (chunk === undefined || chunk.type !== "chunk") return;
+ for (const imported of chunk.imports) walk(imported);
+ };
+ walk(rootFileName);
+ return fileNames;
};
- walk(entry.fileName);
- const bootChunks: BundleBootChunk[] = [];
- for (const fileName of [...bootFileNames].sort()) {
- const chunk = bundle[fileName];
- if (chunk === undefined || chunk.type !== "chunk") continue;
- const packages = new Set();
- for (const moduleId of chunk.moduleIds ?? []) {
- const name = packageNameOf(moduleId);
- if (name !== null) packages.add(name);
+ const describeChunks = (fileNames: Set): BundleBootChunk[] => {
+ const chunks: BundleBootChunk[] = [];
+ for (const fileName of [...fileNames].sort()) {
+ const chunk = bundle[fileName];
+ if (chunk === undefined || chunk.type !== "chunk") continue;
+ const packages = new Set();
+ for (const moduleId of chunk.moduleIds ?? []) {
+ const name = packageNameOf(moduleId);
+ if (name !== null) packages.add(name);
+ }
+ chunks.push({
+ fileName,
+ bytes: Buffer.byteLength(chunk.code),
+ packages: [...packages].sort(),
+ });
}
- bootChunks.push({
- fileName,
- bytes: Buffer.byteLength(chunk.code),
- packages: [...packages].sort(),
- });
+ return chunks;
+ };
+
+ const bootChunks = describeChunks(
+ collectStaticChunkClosure(entry.fileName),
+ );
+ const workspaceRouteEntry = Object.values(bundle).find(
+ (output) =>
+ output.type === "chunk" &&
+ output.facadeModuleId
+ ?.replaceAll("\\", "/")
+ .endsWith("/src/views/SplitWorkspaceRoute.tsx"),
+ );
+ if (
+ workspaceRouteEntry === undefined ||
+ workspaceRouteEntry.type !== "chunk"
+ ) {
+ this.error("Could not find the SplitWorkspaceRoute build entry");
+ }
+ const workspaceRouteChunks = describeChunks(
+ collectStaticChunkClosure(workspaceRouteEntry.fileName),
+ );
+ const workspaceCheckoutDisplayOutput = Object.values(bundle).find(
+ (output) =>
+ output.type === "chunk" &&
+ output.moduleIds.some((moduleId) =>
+ moduleId
+ .replaceAll("\\", "/")
+ .endsWith("/src/lib/workspace-checkout-display.ts"),
+ ),
+ );
+ if (
+ workspaceCheckoutDisplayOutput === undefined ||
+ workspaceCheckoutDisplayOutput.type !== "chunk"
+ ) {
+ this.error("Could not find the workspace checkout display chunk");
+ }
+ const workspaceCheckoutDisplayChunk = describeChunks(
+ new Set([workspaceCheckoutDisplayOutput.fileName]),
+ )[0];
+ if (workspaceCheckoutDisplayChunk === undefined) {
+ this.error("Could not describe the workspace checkout display chunk");
}
- const stats: BundleStats = { entry: entry.fileName, bootChunks };
+ const stats: BundleStats = {
+ entry: entry.fileName,
+ bootChunks,
+ workspaceRouteEntry: workspaceRouteEntry.fileName,
+ workspaceRouteChunks,
+ workspaceCheckoutDisplayChunk,
+ };
const target = resolve(appDir, "bundle-stats.json");
await mkdir(dirname(target), { recursive: true });
await writeFile(target, `${JSON.stringify(stats, null, 2)}\n`);
@@ -76,6 +132,7 @@ function packageNameOf(moduleId: string): string | null {
const segments = moduleId.slice(marker + "node_modules/".length).split("/");
const [first, second] = segments;
if (first === undefined) return null;
- if (first.startsWith("@")) return second === undefined ? null : `${first}/${second}`;
+ if (first.startsWith("@"))
+ return second === undefined ? null : `${first}/${second}`;
return first;
}