From 7e80c85f16c11c701cf645965cee2382c153be3c Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 7 Nov 2026 18:55:53 +0530 Subject: [PATCH 1/4] add: improve dashboard performance and sign-in error handling --- .../features/job-detail/diff-file-panel.tsx | 108 ++++++++-- .../features/job-detail/job-diffs.tsx | 47 ++++- .../features/job-logs/file-review-list.tsx | 58 ++++++ apps/dashboard/src/hooks/use-job-detail.ts | 2 + apps/dashboard/src/hooks/use-polling.ts | 62 ++++-- apps/dashboard/src/lib/diffs-cache.ts | 54 ++++- apps/dashboard/src/lib/json-size.ts | 50 +++++ apps/dashboard/src/pages/job-logs.tsx | 61 ++++-- apps/dashboard/src/pages/landing.tsx | 21 ++ apps/dashboard/src/routes.tsx | 7 + test/e2e/performance-behavior.spec.tsx | 186 ++++++++++++++++++ test/e2e/router-extensions.spec.tsx | 13 +- 12 files changed, 602 insertions(+), 67 deletions(-) create mode 100644 apps/dashboard/src/components/features/job-logs/file-review-list.tsx create mode 100644 apps/dashboard/src/lib/json-size.ts create mode 100644 test/e2e/performance-behavior.spec.tsx diff --git a/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx b/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx index 8f581084..6505f477 100644 --- a/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx +++ b/apps/dashboard/src/components/features/job-detail/diff-file-panel.tsx @@ -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 { @@ -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; +}) { + const viewportRef = useRef(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 ( +
setScrollTop(event.currentTarget.scrollTop)} + className="thin-scroll max-h-[min(70svh,42rem)] overflow-auto" + > +
+
+ {rows.slice(start, end).map((row) => ( + + ))} +
+
+
+ ); +} + export interface FileDiffProps { file: FileReviewRecord; open: boolean; @@ -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" ? ( -
-
- {segment.rows.map((row) => ( - - ))} + segments.map((segment) => { + if (segment.type === "rows") { + return segment.rows.length > LARGE_DIFF_ROWS ? ( + + ) : ( +
+
+ {segment.rows.map((row) => ( + + ))} +
-
- ) : ( + ); + } + + return (
- ), - ) + ); + }) )} {hiddenLines > 0 && ( diff --git a/apps/dashboard/src/components/features/job-detail/job-diffs.tsx b/apps/dashboard/src/components/features/job-detail/job-diffs.tsx index 82f85760..64759b33 100644 --- a/apps/dashboard/src/components/features/job-detail/job-diffs.tsx +++ b/apps/dashboard/src/components/features/job-detail/job-diffs.tsx @@ -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"; @@ -51,26 +55,51 @@ interface JobDiffsProps { } export function JobDiffs({ job }: JobDiffsProps) { - const [diffsByPath, setDiffsByPath] = useState | 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; diff --git a/apps/dashboard/src/components/features/job-logs/file-review-list.tsx b/apps/dashboard/src/components/features/job-logs/file-review-list.tsx new file mode 100644 index 00000000..682fe58f --- /dev/null +++ b/apps/dashboard/src/components/features/job-logs/file-review-list.tsx @@ -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; +}) { + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [jobId]); + + const visibleFiles = files.slice(0, visibleCount); + const remaining = files.length - visibleFiles.length; + + return ( + <> +
+ {visibleFiles.map((file) => ( + + ))} +
+ {remaining > 0 && ( +
+ +

+ {visibleFiles.length} of {files.length} files shown +

+
+ )} + + ); +} diff --git a/apps/dashboard/src/hooks/use-job-detail.ts b/apps/dashboard/src/hooks/use-job-detail.ts index bf6192f7..3581457a 100644 --- a/apps/dashboard/src/hooks/use-job-detail.ts +++ b/apps/dashboard/src/hooks/use-job-detail.ts @@ -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). */ @@ -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); diff --git a/apps/dashboard/src/hooks/use-polling.ts b/apps/dashboard/src/hooks/use-polling.ts index f4ddc747..610cbcb9 100644 --- a/apps/dashboard/src/hooks/use-polling.ts +++ b/apps/dashboard/src/hooks/use-polling.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; export function usePolling( callback: () => Promise | void, - delay = 10_000, + delay: number | null = 10_000, deps: any[] = [], ) { const savedCallback = useRef(callback); @@ -12,42 +12,62 @@ export function usePolling( }, [callback]); useEffect(() => { - savedCallback.current(); + let active = document.visibilityState === "visible"; + let running = false; + let timeout: ReturnType | null = null; + let disposed = false; - if (delay === null) return; + const clearTimeoutIfScheduled = () => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + }; - let id: ReturnType | 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); }; diff --git a/apps/dashboard/src/lib/diffs-cache.ts b/apps/dashboard/src/lib/diffs-cache.ts index 5af6901d..3284035d 100644 --- a/apps/dashboard/src/lib/diffs-cache.ts +++ b/apps/dashboard/src/lib/diffs-cache.ts @@ -1,3 +1,5 @@ +import { isJsonWithinBudget } from "./json-size"; + /** * Session cache for a job's per-file diffs, shared by the Files-changed tab and the Logs page so * switching between them doesn't refetch (`diff_input` isn't persisted in Postgres - see @@ -6,13 +8,55 @@ const cacheKey = (jobId: string) => `codra:job-diffs:${jobId}`; -// Serializing multi-MB diffs janks the main thread and evicts sessionStorage; giant PRs just refetch instead. const MAX_CACHED_CHARS = 2_000_000; +export const DIFFS_CACHE_FRESH_MS = 30_000; + +export interface DiffsCacheEntry { + diffs: Record; + savedAt: number; +} + +function isDiffMap(value: unknown): value is Record { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.values(value).every((item) => typeof item === "string") + ); +} + +export function isDiffsCacheFresh( + entry: DiffsCacheEntry | null, + now = Date.now(), +) { + return ( + !!entry && + entry.savedAt > 0 && + entry.savedAt <= now + 60_000 && + now - entry.savedAt <= DIFFS_CACHE_FRESH_MS + ); +} -export function readDiffsCache(jobId: string): Record | null { +export function readDiffsCache(jobId: string): DiffsCacheEntry | null { try { const raw = sessionStorage.getItem(cacheKey(jobId)); - return raw ? (JSON.parse(raw) as Record) : null; + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === "object" && + "version" in parsed && + parsed.version === 1 && + "savedAt" in parsed && + typeof parsed.savedAt === "number" && + "diffs" in parsed && + isDiffMap(parsed.diffs) + ) { + return { diffs: parsed.diffs, savedAt: parsed.savedAt }; + } + // Older cache entries stored the diff map directly; display them once and + // refresh because their age is unknown. + return isDiffMap(parsed) ? { diffs: parsed, savedAt: 0 } : null; } catch { return null; } @@ -20,7 +64,9 @@ export function readDiffsCache(jobId: string): Record | null { export function writeDiffsCache(jobId: string, diffs: Record) { try { - const payload = JSON.stringify(diffs); + const entry = { version: 1, savedAt: Date.now(), diffs }; + if (!isJsonWithinBudget(entry, MAX_CACHED_CHARS)) return; + const payload = JSON.stringify(entry); if (payload.length > MAX_CACHED_CHARS) return; sessionStorage.setItem(cacheKey(jobId), payload); } catch { diff --git a/apps/dashboard/src/lib/json-size.ts b/apps/dashboard/src/lib/json-size.ts new file mode 100644 index 00000000..7bf1f6af --- /dev/null +++ b/apps/dashboard/src/lib/json-size.ts @@ -0,0 +1,50 @@ +/** + * Conservatively checks whether JSON.stringify could fit in a storage budget + * without first allocating the serialized string. Strings are counted at their + * worst-case JSON escape size (six output characters per UTF-16 code unit). + */ +export function isJsonWithinBudget(value: unknown, maxChars: number) { + let upperBound = 0; + const seen = new WeakSet(); + + const add = (chars: number) => { + upperBound += chars; + return upperBound <= maxChars; + }; + + const addString = (text: string) => add(text.length * 6 + 2); + + const visit = (item: unknown, depth: number): boolean => { + if (depth > 100) return false; + if (item === null) return add(4); + if (typeof item === "string") return addString(item); + if (typeof item === "number") return add(32); + if (typeof item === "boolean") return add(item ? 5 : 6); + if (typeof item === "undefined") return add(6); + if (typeof item !== "object") return false; + if (seen.has(item)) return false; + seen.add(item); + + if (Array.isArray(item)) { + if (!add(2)) return false; + for (let i = 0; i < item.length; i++) { + if (i > 0 && !add(1)) return false; + if (!visit(item[i], depth + 1)) return false; + } + return true; + } + + if (!add(2)) return false; + let first = true; + for (const key in item) { + if (!Object.prototype.hasOwnProperty.call(item, key)) continue; + if (!first && !add(1)) return false; + first = false; + if (!addString(key) || !add(1) || !visit((item as Record)[key], depth + 1)) + return false; + } + return true; + }; + + return visit(value, 0); +} diff --git a/apps/dashboard/src/pages/job-logs.tsx b/apps/dashboard/src/pages/job-logs.tsx index cb5c9c81..59be9881 100644 --- a/apps/dashboard/src/pages/job-logs.tsx +++ b/apps/dashboard/src/pages/job-logs.tsx @@ -2,7 +2,11 @@ import { LoadError } from "@codraoss/ui"; import { useEffect, useMemo, useState } from "react"; import { useParams, Link } from "react-router-dom"; -import { readDiffsCache, writeDiffsCache } from "@client/lib/diffs-cache"; +import { + isDiffsCacheFresh, + readDiffsCache, + writeDiffsCache, +} from "@client/lib/diffs-cache"; import { groupBatches } from "@client/lib/batch-groups"; import { ChevronLeft, @@ -14,32 +18,53 @@ import { useJobDetail } from "@client/hooks/use-job-detail"; import { JobDetailSkeleton } from "@client/components/features/job-detail/job-skeleton"; import { api } from "@client/lib/api"; -import { FileRow, withheldTotal } from "@client/components/features/job-logs/file-row"; +import { withheldTotal } from "@client/components/features/job-logs/file-row"; +import { FileReviewList } from "@client/components/features/job-logs/file-review-list"; export function JobLogsPage() { const { id = "" } = useParams(); const { job, error } = useJobDetail(id); - const [diffsByPath, setDiffsByPath] = useState | null>( - () => readDiffsCache(id), - ); - const [diffsLoading, setDiffsLoading] = useState(diffsByPath === null); + const [diffState, setDiffState] = useState(() => { + const entry = readDiffsCache(id); + return { jobId: id, entry, loading: !isDiffsCacheFresh(entry) }; + }); + const cacheEntry = diffState.jobId === id ? diffState.entry : null; + const diffsByPath = cacheEntry?.diffs ?? null; + const diffsLoading = diffState.jobId === id ? diffState.loading : true; useEffect(() => { + const cached = readDiffsCache(id); + setDiffState({ jobId: id, entry: cached, loading: !isDiffsCacheFresh(cached) }); + if (isDiffsCacheFresh(cached)) return; + let cancelled = false; - setDiffsLoading(true); api .getJobDiffs(id) .then((res) => { if (cancelled) return; - setDiffsByPath(res.diffs); + setDiffState({ + jobId: id, + entry: { diffs: res.diffs, savedAt: Date.now() }, + loading: false, + }); writeDiffsCache(id, res.diffs); }) .catch(() => { - if (!cancelled) setDiffsByPath((current) => current ?? {}); + if (!cancelled) { + setDiffState((current) => + current.jobId === id + ? { ...current, entry: current.entry ?? { diffs: {}, savedAt: 0 }, loading: false } + : current, + ); + } }) .finally(() => { - if (!cancelled) setDiffsLoading(false); + if (!cancelled) { + setDiffState((current) => + current.jobId === id ? { ...current, loading: false } : current, + ); + } }); return () => { cancelled = true; @@ -213,16 +238,12 @@ export function JobLogsPage() { {counts.total} {counts.total === 1 ? "file" : "files"} -
- {files.map((file) => ( - - ))} -
+ )} diff --git a/apps/dashboard/src/pages/landing.tsx b/apps/dashboard/src/pages/landing.tsx index a67d792d..5bef61a2 100644 --- a/apps/dashboard/src/pages/landing.tsx +++ b/apps/dashboard/src/pages/landing.tsx @@ -1,6 +1,7 @@ import { Button, GithubMark, LinkButton } from "@codraoss/ui"; import { Sun, Moon } from "lucide-react"; import { useTheme } from "@codraoss/ui/theme"; +import { useSearchParams } from "react-router-dom"; const TERMS_URL = "https://codra.run/terms"; const PRIVACY_URL = "https://codra.run/privacy"; @@ -30,6 +31,17 @@ const legalLink = export function LandingPage() { const { theme, toggleTheme } = useTheme(); + const [searchParams] = useSearchParams(); + const authError = searchParams.get("error"); + const authErrorMessage = authError + ? { + not_allowed: "This GitHub account is not allowed to access this dashboard.", + invalid_callback: "GitHub sign-in could not be completed. Please try again.", + invalid_state: "The sign-in request expired. Please try again.", + oauth_failed: "GitHub sign-in failed. Please try again.", + access_denied: "GitHub sign-in was cancelled.", + }[authError] ?? "GitHub sign-in failed. Please try again." + : null; return (
@@ -100,6 +112,15 @@ export function LandingPage() {

+ {authErrorMessage && ( +

+ {authErrorMessage} +

+ )} + , errorElement: , }, + { + // OAuth failures return to /login?error=...; keep the app route aligned with + // the server's public /login entry instead of falling through to NotFoundPage. + path: "/login", + element: , + errorElement: , + }, ]; // A boundary per child too, not just on the layout: React Router replaces the whole matched branch, so a branch-only boundary would take the sidebar and header down with a single page. diff --git a/test/e2e/performance-behavior.spec.tsx b/test/e2e/performance-behavior.spec.tsx new file mode 100644 index 00000000..473c7d61 --- /dev/null +++ b/test/e2e/performance-behavior.spec.tsx @@ -0,0 +1,186 @@ +/** + * @vitest-environment jsdom + */ +import { + act, + fireEvent, + render, + renderHook, + screen, +} from "@testing-library/react"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { usePolling } from "@client/hooks/use-polling"; +import { isJsonWithinBudget } from "@client/lib/json-size"; +import { + isDiffsCacheFresh, + readDiffsCache, + writeDiffsCache, +} from "@client/lib/diffs-cache"; +import { FileDiff } from "@client/components/features/job-detail/diff-file-panel"; +import { FileReviewList } from "@client/components/features/job-logs/file-review-list"; + +describe("performance safeguards", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.useFakeTimers(); + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => "visible", + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("waits for each poll to finish before scheduling the next one", async () => { + let finishFirst!: () => void; + const firstRequest = new Promise((resolve) => { + finishFirst = resolve; + }); + const callback = vi + .fn<() => Promise | void>() + .mockReturnValueOnce(firstRequest) + .mockReturnValue(undefined); + + const { unmount } = renderHook(() => usePolling(callback, 1_000)); + expect(callback).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(3_000); + }); + expect(callback).toHaveBeenCalledTimes(1); + + await act(async () => { + finishFirst(); + await firstRequest; + }); + await act(async () => { + vi.advanceTimersByTime(999); + }); + expect(callback).toHaveBeenCalledTimes(1); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(callback).toHaveBeenCalledTimes(2); + unmount(); + }); + + it("pauses polling while hidden and refreshes when visible again", async () => { + let visibility: DocumentVisibilityState = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibility, + }); + const callback = vi.fn(); + const { unmount } = renderHook(() => usePolling(callback, 1_000)); + expect(callback).toHaveBeenCalledTimes(1); + + visibility = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + await act(async () => { + vi.advanceTimersByTime(3_000); + }); + expect(callback).toHaveBeenCalledTimes(1); + + visibility = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + expect(callback).toHaveBeenCalledTimes(2); + unmount(); + }); + + it("stores fresh diff-cache entries and treats legacy entries as stale", () => { + writeDiffsCache("job-1", { "src/a.ts": "diff" }); + const entry = readDiffsCache("job-1"); + expect(entry?.diffs["src/a.ts"]).toBe("diff"); + expect(isDiffsCacheFresh(entry)).toBe(true); + + sessionStorage.setItem("codra:job-diffs:legacy", JSON.stringify({ a: "old" })); + const legacy = readDiffsCache("legacy"); + expect(legacy?.diffs).toEqual({ a: "old" }); + expect(isDiffsCacheFresh(legacy)).toBe(false); + }); + + it("rejects oversized JSON before constructing a serialized copy", () => { + expect(isJsonWithinBudget({ diffs: { a: "x".repeat(400_000) } }, 2_000_000)).toBe( + false, + ); + writeDiffsCache("large", { a: "x".repeat(400_000) }); + expect(sessionStorage.getItem("codra:job-diffs:large")).toBeNull(); + }); + + it("keeps large expanded diffs to the viewport plus overscan", () => { + const lines = Array.from({ length: 700 }, (_, index) => `+const line${index} = ${index};`); + const file = { + id: "file-1", + jobId: "job-1", + filePath: "src/large.ts", + fileStatus: "done", + modelUsed: "", + diffLineCount: 700, + diffInput: `@@ -1,700 +1,700 @@\n${lines.join("\n")}`, + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: null, + verdict: null, + fileSummary: null, + errorMessage: null, + createdAt: new Date().toISOString(), + } as any; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Show full diff" })); + const region = screen.getByRole("region", { name: "Diff lines" }); + const renderedLines = region.querySelectorAll(".diff-add").length; + expect(renderedLines).toBeGreaterThan(0); + expect(renderedLines).toBeLessThan(100); + }); + + it("renders large job log lists in bounded batches", () => { + const files = Array.from({ length: 260 }, (_, index) => ({ + id: `file-${index}`, + jobId: "job-1", + filePath: `src/file-${index}.ts`, + fileStatus: "pending", + modelUsed: "", + diffLineCount: null, + diffInput: "", + rawAiOutput: null, + parsedComments: [], + inputTokens: null, + outputTokens: null, + durationMs: null, + verdict: null, + fileSummary: null, + errorMessage: null, + createdAt: new Date().toISOString(), + })) as any; + const { container } = render( + , + ); + + expect(container.querySelectorAll("details")).toHaveLength(100); + fireEvent.click(screen.getByRole("button", { name: "Show 100 more files" })); + expect(container.querySelectorAll("details")).toHaveLength(200); + fireEvent.click(screen.getByRole("button", { name: "Show 60 more files" })); + expect(container.querySelectorAll("details")).toHaveLength(260); + expect(screen.queryByRole("button", { name: /show .* more files/i })).toBeNull(); + }); +}); diff --git a/test/e2e/router-extensions.spec.tsx b/test/e2e/router-extensions.spec.tsx index 695d22c1..d1b724e3 100644 --- a/test/e2e/router-extensions.spec.tsx +++ b/test/e2e/router-extensions.spec.tsx @@ -50,13 +50,24 @@ describe("Dashboard route and nav registries (JSDOM)", () => { }); it("exposes the built-in routes as composable arrays", () => { - expect(publicRoutes.map((r) => r.path)).toEqual(["/"]); + expect(publicRoutes.map((r) => r.path)).toEqual(["/", "/login"]); expect(shellRoutes.map((r) => r.path)).toContain("dashboard"); expect([...publicRoutes, ...shellRoutes].map((r) => r.path)).not.toContain( "*", ); }); + it("renders the sign-in page and OAuth error on the login route", async () => { + renderAt("/login?error=not_allowed", () => buildRouter()); + + expect( + await screen.findByRole("heading", { name: "Sign in to Codra" }), + ).toBeTruthy(); + expect( + screen.getByRole("alert").textContent, + ).toMatch(/not allowed to access this dashboard/i); + }); + it("renders an injected shell route and its nav entry", async () => { const { container } = renderAt("/teams", () => buildRouter({ From 71448b7e6ed806271dde7a78e8dafd658502f706 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 7 Nov 2026 19:23:45 +0530 Subject: [PATCH 2/4] add: redirect signed-in users and refine landing layout --- apps/dashboard/src/pages/landing.tsx | 46 ++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/pages/landing.tsx b/apps/dashboard/src/pages/landing.tsx index 5bef61a2..c8b7d59d 100644 --- a/apps/dashboard/src/pages/landing.tsx +++ b/apps/dashboard/src/pages/landing.tsx @@ -1,7 +1,9 @@ import { Button, GithubMark, LinkButton } from "@codraoss/ui"; +import { useEffect } from "react"; import { Sun, Moon } from "lucide-react"; import { useTheme } from "@codraoss/ui/theme"; -import { useSearchParams } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { api } from "@client/lib/api"; const TERMS_URL = "https://codra.run/terms"; const PRIVACY_URL = "https://codra.run/privacy"; @@ -31,6 +33,8 @@ const legalLink = export function LandingPage() { const { theme, toggleTheme } = useTheme(); + const { pathname } = useLocation(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const authError = searchParams.get("error"); const authErrorMessage = authError @@ -43,8 +47,28 @@ export function LandingPage() { }[authError] ?? "GitHub sign-in failed. Please try again." : null; + useEffect(() => { + if (pathname !== "/" || authError) return; + + let cancelled = false; + api + .getSession() + .then((session) => { + if (!cancelled && session.user) { + navigate("/dashboard", { replace: true }); + } + }) + .catch(() => { + // A missing or expired session should leave the sign-in page available. + }); + + return () => { + cancelled = true; + }; + }, [authError, navigate, pathname]); + return ( -
+
-
+
@@ -144,6 +168,22 @@ export function LandingPage() {

+ + {pathname === "/" && ( + + )}
); } From 9bd7df4921c2fbba74a8c2557aeae9afb95bee99 Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 7 Nov 2026 19:28:56 +0530 Subject: [PATCH 3/4] fix: handle missing sessions in landing redirect --- apps/dashboard/src/pages/landing.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/pages/landing.tsx b/apps/dashboard/src/pages/landing.tsx index c8b7d59d..70fc5bae 100644 --- a/apps/dashboard/src/pages/landing.tsx +++ b/apps/dashboard/src/pages/landing.tsx @@ -51,10 +51,9 @@ export function LandingPage() { if (pathname !== "/" || authError) return; let cancelled = false; - api - .getSession() + Promise.resolve(api.getSession()) .then((session) => { - if (!cancelled && session.user) { + if (!cancelled && session?.user) { navigate("/dashboard", { replace: true }); } }) From bb3589d2664de6cb27f9d6543f14d81f775bd2cf Mon Sep 17 00:00:00 2001 From: Devarshi Shimpi Date: Sat, 7 Nov 2026 19:36:46 +0530 Subject: [PATCH 4/4] fix: improve JSON size checks for shared references - Track only active ancestors so repeated references are handled without rejecting valid JSON - Remove objects from the ancestor set after traversal --- apps/dashboard/src/lib/json-size.ts | 45 +++++++++++++++++------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/apps/dashboard/src/lib/json-size.ts b/apps/dashboard/src/lib/json-size.ts index 7bf1f6af..1b39a032 100644 --- a/apps/dashboard/src/lib/json-size.ts +++ b/apps/dashboard/src/lib/json-size.ts @@ -5,7 +5,7 @@ */ export function isJsonWithinBudget(value: unknown, maxChars: number) { let upperBound = 0; - const seen = new WeakSet(); + const ancestors = new WeakSet(); const add = (chars: number) => { upperBound += chars; @@ -22,28 +22,37 @@ export function isJsonWithinBudget(value: unknown, maxChars: number) { if (typeof item === "boolean") return add(item ? 5 : 6); if (typeof item === "undefined") return add(6); if (typeof item !== "object") return false; - if (seen.has(item)) return false; - seen.add(item); + if (ancestors.has(item)) return false; + ancestors.add(item); + + try { + if (Array.isArray(item)) { + if (!add(2)) return false; + for (let i = 0; i < item.length; i++) { + if (i > 0 && !add(1)) return false; + if (!visit(item[i], depth + 1)) return false; + } + return true; + } - if (Array.isArray(item)) { if (!add(2)) return false; - for (let i = 0; i < item.length; i++) { - if (i > 0 && !add(1)) return false; - if (!visit(item[i], depth + 1)) return false; + let first = true; + for (const key in item) { + if (!Object.prototype.hasOwnProperty.call(item, key)) continue; + if (!first && !add(1)) return false; + first = false; + if ( + !addString(key) || + !add(1) || + !visit((item as Record)[key], depth + 1) + ) { + return false; + } } return true; + } finally { + ancestors.delete(item); } - - if (!add(2)) return false; - let first = true; - for (const key in item) { - if (!Object.prototype.hasOwnProperty.call(item, key)) continue; - if (!first && !add(1)) return false; - first = false; - if (!addString(key) || !add(1) || !visit((item as Record)[key], depth + 1)) - return false; - } - return true; }; return visit(value, 0);