open-swe/ui/src/features/agents/utils/diffUtils.ts
Adam Moussa 4d51410dc8
refactor: move ui/src/{components,lib}/agents into features/ layout
Part of the domain-reorg adoption (build plan step C6): fork content,
upstream layout. git mv's all 80 pure-rename files from the move-map
(agent-scoped components/lib -> ui/src/features/{agents,automations,
reviews,settings}/...), including ported/ splitting into
features/agents/experiments/ (still-unintegrated desktop-host ports)
and features/agents/components/chat/ (CloudPromptBar, CodeBlock,
DiffView, Logo, Markdown, ReplyCard, ShellCommand, ToolExecution --
files actually wired into the live dashboard). Fork-diverged files
(PlanReview.tsx, WorkflowApprovalCard.tsx, AgentsSidebar.tsx,
SidebarFilterMenu.tsx, AgentGitPanel.tsx, AutomationEditor.tsx,
DiffView.tsx, CloudPromptBar.tsx) keep fork content -- verified via
diff that only import lines changed.

Rewrote @/components/agents and @/lib/agents imports across all 54
importer files plus the moved files' own internal imports (grep-driven,
85-entry alias map covering every old->new path pair). Two hazards
caught only by the build gate (not tsc, since both files sit under the
new experiments/** tsconfig exclude): ui/src/lib/notifications.ts had
a relative `./agents/types` import (no `@/` alias) that the grep missed;
and features/agents/experiments/index.ts's barrel re-export of the
messages module needed to switch from an `@/` alias to a relative
import (`../components/messages`) after landing inside the newly
tsconfig-excluded experiments/ directory -- vite-tsconfig-paths failed
to resolve it at build time even though tsc stayed silent.

AgentPromptBar.tsx (the 2-line CloudPromptBar re-export shim) moves to
features/agents/components/ with its export path retargeted at
CloudPromptBar's new chat/ location -- a D/A pair, not a content loss,
since the import-path edit drops it below git's rename-similarity
threshold. ported/index.ts is the same D/A story for the same reason.

Swapped the 8-file ported/ exclude lists in tsconfig.json and
eslint.config.js for the single `src/features/agents/experiments/**`
glob (upstream's simplification, no behavior change). Deleted
ui/pnpm-workspace.yaml (lockfile hygiene -- bun stays the toolchain).

Gates: bunx tsc --noEmit (clean), bun run build (clean after the two
notifications.ts / experiments-index.ts fixes above), bun run test
(33/33), and the full Playwright E2E suite against the real langgraph
dev server + built dashboard (9/9).
2026-07-17 14:51:12 -04:00

190 lines
6.6 KiB
TypeScript

import { useMemo } from "react"
import { preloadHighlighter } from "@pierre/diffs"
import type {
VirtualFileMetrics,
WorkerInitializationRenderOptions,
WorkerPoolOptions,
} from "@pierre/diffs/react"
import { useResolvedTheme } from "@/lib/theme"
export type DiffStyle = "unified" | "split"
export const DIFF_UNSAFE_CSS = `
[data-diffs-header],
[data-diff],
[data-file],
[data-error-wrapper],
[data-virtualizer-buffer] {
--diffs-bg: var(--ui-panel) !important;
--diffs-light-bg: var(--ui-panel) !important;
--diffs-dark-bg: var(--ui-panel) !important;
--diffs-token-light-bg: transparent;
--diffs-token-dark-bg: transparent;
--diffs-bg-context-override: var(--ui-panel);
--diffs-bg-hover-override: var(--ui-panel-2);
--diffs-bg-separator-override: var(--ui-accent-bubble);
--diffs-bg-buffer-override: var(--ui-bg);
--diffs-bg-addition-override: color-mix(in srgb, var(--ui-panel) 80%, #22c55e);
--diffs-bg-addition-number-override: color-mix(in srgb, var(--ui-panel) 75%, #22c55e);
--diffs-bg-addition-hover-override: color-mix(in srgb, var(--ui-panel) 70%, #22c55e);
--diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--ui-panel) 60%, #22c55e);
--diffs-bg-deletion-override: color-mix(in srgb, var(--ui-panel) 80%, #ef4444);
--diffs-bg-deletion-number-override: color-mix(in srgb, var(--ui-panel) 75%, #ef4444);
--diffs-bg-deletion-hover-override: color-mix(in srgb, var(--ui-panel) 70%, #ef4444);
--diffs-bg-deletion-emphasis-override: color-mix(in srgb, var(--ui-panel) 60%, #ef4444);
--diffs-fg-number-override: var(--ui-text-dim);
--diffs-font-size: 12px;
--diffs-line-height: 1.5;
--diffs-font-family: "SF Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, monospace;
background-color: var(--ui-panel) !important;
}
[data-file-info] {
background-color: var(--ui-accent-bubble) !important;
border-block-color: var(--ui-border) !important;
color: var(--ui-text) !important;
}
[data-diffs-header] {
position: sticky !important;
top: 0;
z-index: 4;
background-color: var(--ui-accent-bubble) !important;
border-bottom: 1px solid var(--ui-border) !important;
}
[data-separator] {
background-color: var(--ui-accent-bubble) !important;
color: var(--ui-text-dim) !important;
}
/* A selected line propagates [data-selected-line] onto its annotation row and
gutter, bleeding the selection background behind inline annotation content.
Keep the code line highlighted, but hold the annotation row at the panel bg. */
[data-line-annotation][data-selected-line],
[data-gutter-buffer="annotation"][data-selected-line] {
--diffs-line-bg: var(--ui-panel) !important;
}
/* Pin every code row to one exact, uniform height (kept in sync with
DIFF_VIRTUAL_METRICS.lineHeight below). In scroll mode code never wraps, so a
hard height won't clip content — it just makes the virtualizer's per-line
estimate match measured layout, so scroll-to lands precisely instead of
over/under-shooting as off-estimate rows reconcile while scrolling. */
[data-line] {
height: 18px !important;
min-height: 18px !important;
max-height: 18px !important;
line-height: 18px !important;
}
`
export const diffOptions = {
theme: { light: "pierre-light", dark: "pierre-dark" } as const,
themeType: "system" as const,
diffStyle: "unified" as const,
overflow: "scroll" as const,
disableFileHeader: true,
unsafeCSS: DIFF_UNSAFE_CSS,
collapsedContextThreshold: 4,
lineDiffType: "word-alt" as const,
maxLineDiffLength: 800,
tokenizeMaxLineLength: 1200,
tokenizeMaxLength: 120_000,
}
export function useDiffOptions(diffStyle: DiffStyle = "unified") {
const resolvedTheme = useResolvedTheme()
return useMemo(
() => ({ ...diffOptions, themeType: resolvedTheme, diffStyle }),
[resolvedTheme, diffStyle]
)
}
// Shared virtualization + worker-pool config for <Virtualizer>/<MultiFileDiff>.
// Tuned for the agent git panel and the PR reviews page; keep them aligned so
// both viewers window rows and offload highlighting identically.
export const DIFF_VIRTUALIZER_CONFIG = {
overscrollSize: 1200,
intersectionObserverMargin: 4800,
}
export const DIFF_VIRTUAL_METRICS = {
hunkLineCount: 80,
// Must match the hard `[data-line]` height pinned in DIFF_UNSAFE_CSS so the
// virtualizer's pre-measurement estimate equals the measured row height.
lineHeight: 18,
diffHeaderHeight: 0,
spacing: 8,
} satisfies Partial<VirtualFileMetrics>
export const DIFF_WORKER_POOL_OPTIONS = {
workerFactory: () =>
new Worker(
new URL("@pierre/diffs/worker/worker-portable.js", import.meta.url),
{ type: "module" }
),
poolSize: 2,
totalASTLRUCacheSize: 120,
} satisfies WorkerPoolOptions
export const DIFF_WORKER_HIGHLIGHTER_OPTIONS = {
theme: { light: "pierre-light", dark: "pierre-dark" },
lineDiffType: "word-alt",
maxLineDiffLength: 800,
tokenizeMaxLineLength: 1200,
langs: ["text"],
} satisfies WorkerInitializationRenderOptions
function hashFileContents(contents: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < contents.length; i++) {
hash ^= contents.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(36)
}
// Stable per-file content key so the worker pool dedupes highlight work across
// re-renders instead of re-tokenizing identical content. Added/removed/binary/
// oversized blobs arrive as null (see pr_diff.py); coerce to "" so the key never
// dereferences null — these files don't render a diff, so the exact key is moot.
export function fileContentsCacheKey(
path: string,
side: "old" | "new",
contents: string | null | undefined
): string {
const text = contents ?? ""
return `${path}:${side}:${text.length}:${hashFileContents(text)}`
}
let highlighterWarmup: Promise<void> | null = null
/**
* Pierre's <MultiFileDiff> renders an empty <diffs-container> on its first mount
* when the shared Shiki highlighter (specifically its themes) hasn't loaded yet:
* the cold-start render bails before painting and relies on an async repaint that
* can be dropped — most reliably under React StrictMode's mount/unmount/mount,
* which leaves a stale empty <pre> behind so the remounted instance no-ops.
*
* Warming the themes up-front makes that first render synchronous and non-empty.
* Idempotent and client-only (preloadHighlighter creates a Shiki instance).
*/
export function warmDiffHighlighter(): Promise<void> {
if (typeof window === "undefined") return Promise.resolve()
if (highlighterWarmup == null) {
highlighterWarmup = preloadHighlighter({
themes: [diffOptions.theme.light, diffOptions.theme.dark],
langs: ["text"],
}).catch((error) => {
highlighterWarmup = null
throw error
})
}
return highlighterWarmup
}