Skip to content
Merged
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
108 changes: 96 additions & 12 deletions apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Badge } from "@codraoss/ui";
import { memo, useMemo, useState } from "react";
import { memo, useMemo, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { StatusBadge } from "./status-badge";
import {
Expand Down Expand Up @@ -68,6 +68,80 @@ const DiffLine = memo(function DiffLine({
);
});

const DIFF_ROW_OVERSCAN_PX = 240;

function rowHeight(row: DiffRow) {
return row.kind === "hunk" ? 28 : 20;
}

function firstRowAtOffset(offsets: number[], offset: number) {
let low = 0;
let high = offsets.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (offsets[middle + 1] <= offset) low = middle + 1;
else high = middle;
}
return low;
}

/** Keep large expanded diffs to the viewport plus a small buffer in the DOM. */
function VirtualDiffRows({
rows,
lang,
}: {
rows: DiffRow[];
lang: ReturnType<typeof langForPath>;
}) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const offsets = useMemo(() => {
const result = [0];
for (const row of rows) result.push(result[result.length - 1] + rowHeight(row));
return result;
}, [rows]);
const maxLineLength = useMemo(
() => rows.reduce((max, row) => Math.max(max, row.text.length), 0),
[rows],
);
const viewportHeight =
viewportRef.current?.clientHeight ||
Math.min(600, Math.max(240, window.innerHeight * 0.7));
const start = firstRowAtOffset(
offsets,
Math.max(0, scrollTop - DIFF_ROW_OVERSCAN_PX),
);
const end = Math.min(
rows.length,
firstRowAtOffset(
offsets,
scrollTop + viewportHeight + DIFF_ROW_OVERSCAN_PX,
) + 1,
);

return (
<div
ref={viewportRef}
role="region"
aria-label="Diff lines"
tabIndex={0}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
className="thin-scroll max-h-[min(70svh,42rem)] overflow-auto"
>
<div
className="ui-font-mono min-w-max"
style={{ minWidth: `${maxLineLength + 14}ch` }}
>
<div aria-hidden style={{ height: offsets[start] }} />
{rows.slice(start, end).map((row) => (
<DiffLine key={rowKey(row)} row={row} lang={lang} />
))}
<div aria-hidden style={{ height: offsets[rows.length] - offsets[end] }} />
</div>
</div>
);
}

export interface FileDiffProps {
file: FileReviewRecord;
open: boolean;
Expand Down Expand Up @@ -246,16 +320,26 @@ export const FileDiff = memo(function FileDiff({
) : (
// Each segment scrolls independently, so comment cards stay at panel width instead of
// stretching to the widest code line in a shared scroller.
segments.map((segment) =>
segment.type === "rows" ? (
<div key={segment.key} className="thin-scroll overflow-x-auto">
<div className="min-w-fit py-1">
{segment.rows.map((row) => (
<DiffLine key={rowKey(row)} row={row} lang={lang} />
))}
segments.map((segment) => {
if (segment.type === "rows") {
return segment.rows.length > LARGE_DIFF_ROWS ? (
<VirtualDiffRows
key={segment.key}
rows={segment.rows}
lang={lang}
/>
) : (
<div key={segment.key} className="thin-scroll overflow-x-auto">
<div className="min-w-fit py-1">
{segment.rows.map((row) => (
<DiffLine key={rowKey(row)} row={row} lang={lang} />
))}
</div>
</div>
</div>
) : (
);
}

return (
<div
key={segment.key}
className="space-y-3 border-y border-ui-line/60 px-3 py-3 sm:px-4"
Expand All @@ -270,8 +354,8 @@ export const FileDiff = memo(function FileDiff({
))}
</div>
</div>
),
)
);
})
)}

{hiddenLines > 0 && (
Expand Down
47 changes: 38 additions & 9 deletions apps/dashboard/src/components/features/job-detail/job-diffs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
import { api } from "@client/lib/api";
import { buildTree } from "@codraoss/ui/file-tree";
import { diffStats } from "@codraoss/ui/prompt-diff";
import { readDiffsCache, writeDiffsCache } from "@client/lib/diffs-cache";
import {
isDiffsCacheFresh,
readDiffsCache,
writeDiffsCache,
} from "@client/lib/diffs-cache";
import type { FileReviewRecord, JobDetail } from "@codraoss/schema";

import { FileDiff } from "./diff-file-panel";
Expand Down Expand Up @@ -51,26 +55,51 @@ interface JobDiffsProps {
}

export function JobDiffs({ job }: JobDiffsProps) {
const [diffsByPath, setDiffsByPath] = useState<Record<string, string> | null>(
() => readDiffsCache(job.id),
);
const [diffsLoading, setDiffsLoading] = useState(diffsByPath === null);
const [diffState, setDiffState] = useState(() => {
const entry = readDiffsCache(job.id);
return {
jobId: job.id,
entry,
loading: !isDiffsCacheFresh(entry),
};
});
const cacheEntry = diffState.jobId === job.id ? diffState.entry : null;
const diffsByPath = cacheEntry?.diffs ?? null;
const diffsLoading =
diffState.jobId === job.id ? diffState.loading : true;

useEffect(() => {
const cached = readDiffsCache(job.id);
setDiffState({ jobId: job.id, entry: cached, loading: !isDiffsCacheFresh(cached) });
if (isDiffsCacheFresh(cached)) return;

let cancelled = false;
setDiffsLoading(true);
api
.getJobDiffs(job.id)
.then((res) => {
if (cancelled) return;
setDiffsByPath(res.diffs);
setDiffState({
jobId: job.id,
entry: { diffs: res.diffs, savedAt: Date.now() },
loading: false,
});
writeDiffsCache(job.id, res.diffs);
})
.catch(() => {
if (!cancelled) setDiffsByPath((current) => current ?? {});
if (!cancelled) {
setDiffState((current) =>
current.jobId === job.id
? { ...current, entry: current.entry ?? { diffs: {}, savedAt: 0 }, loading: false }
: current,
);
}
})
.finally(() => {
if (!cancelled) setDiffsLoading(false);
if (!cancelled) {
setDiffState((current) =>
current.jobId === job.id ? { ...current, loading: false } : current,
);
}
});
return () => {
cancelled = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import type { FileReviewRecord } from "@codraoss/schema";
import type { BatchGroup } from "@client/lib/batch-groups";
import { FileRow } from "./file-row";

const PAGE_SIZE = 100;

export function FileReviewList({
jobId,
files,
diffsLoading,
batches,
}: {
jobId: string;
files: FileReviewRecord[];
diffsLoading: boolean;
batches: Map<string, BatchGroup>;
}) {
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);

useEffect(() => {
setVisibleCount(PAGE_SIZE);
}, [jobId]);

const visibleFiles = files.slice(0, visibleCount);
const remaining = files.length - visibleFiles.length;

return (
<>
<div className="divide-y divide-ui-line/60">
{visibleFiles.map((file) => (
<FileRow
key={file.id}
file={file}
diffsLoading={diffsLoading}
batch={batches.get(file.filePath)}
/>
))}
</div>
{remaining > 0 && (
<div className="border-t border-ui-line px-4 py-3 text-center">
<button
type="button"
onClick={() =>
setVisibleCount((count) => Math.min(files.length, count + PAGE_SIZE))
}
className="text-xs font-medium text-primary hover:underline"
>
Show {Math.min(remaining, PAGE_SIZE)} more files
</button>
<p className="mt-1 text-[11px] text-ui-subtle">
{visibleFiles.length} of {files.length} files shown
</p>
</div>
)}
</>
);
}
2 changes: 2 additions & 0 deletions apps/dashboard/src/hooks/use-job-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { api } from "@client/lib/api";
import { isJsonWithinBudget } from "@client/lib/json-size";
import type { JobDetail } from "@codraoss/schema";

/* Job detail carries every file's full diff, so cache writes are best-effort (quota is swallowed). */
Expand All @@ -23,6 +24,7 @@ const MAX_CACHED_CHARS = 1_000_000;

function writeJobCache(id: string, job: JobDetail) {
try {
if (!isJsonWithinBudget(job, MAX_CACHED_CHARS)) return;
const payload = JSON.stringify(job);
if (payload.length > MAX_CACHED_CHARS) return;
sessionStorage.setItem(jobCacheKey(id), payload);
Expand Down
62 changes: 41 additions & 21 deletions apps/dashboard/src/hooks/use-polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";

export function usePolling(
callback: () => Promise<void> | void,
delay = 10_000,
delay: number | null = 10_000,
deps: any[] = [],
) {
const savedCallback = useRef(callback);
Expand All @@ -12,42 +12,62 @@ export function usePolling(
}, [callback]);

useEffect(() => {
savedCallback.current();
let active = document.visibilityState === "visible";
let running = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
let disposed = false;

if (delay === null) return;
const clearTimeoutIfScheduled = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
};

let id: ReturnType<typeof setInterval> | null = null;
const schedule = () => {
clearTimeoutIfScheduled();
if (!disposed && active && delay !== null) {
timeout = setTimeout(() => void run(), delay);
}
};

const run = async () => {
if (disposed || !active || running) return;
running = true;
try {
await savedCallback.current();
} catch {
// Polling callbacks own user-facing error state. A rejected callback must
// not create an unhandled rejection or stop future polls.
} finally {
running = false;
schedule();
}
};

const start = () => {
if (id !== null) return;
id = setInterval(() => {
savedCallback.current();
}, delay);
if (active) return;
active = true;
clearTimeoutIfScheduled();
void run();
};

const stop = () => {
if (id !== null) {
clearInterval(id);
id = null;
}
active = false;
clearTimeoutIfScheduled();
};

if (document.visibilityState === "visible") {
start();
}
if (active) void run();

const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
savedCallback.current();
start();
} else {
stop();
}
if (document.visibilityState === "visible") start();
else stop();
};

document.addEventListener("visibilitychange", onVisibilityChange);

return () => {
disposed = true;
stop();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
Expand Down
Loading
Loading