Skip to content

Optimize fragment rendering and homepage link generation#67

Open
baanish wants to merge 4 commits into
mainfrom
codex/optimize-codebase
Open

Optimize fragment rendering and homepage link generation#67
baanish wants to merge 4 commits into
mainfrom
codex/optimize-codebase

Conversation

@baanish
Copy link
Copy Markdown
Owner

@baanish baanish commented May 12, 2026

Summary

  • Tightened fragment, ARX, diff, markdown, CSV, and JSON hot paths with loop-based and cached implementations.
  • Reduced homepage and viewer shell work by precomputing sample metadata and caching fragment module loading.
  • Added build-budget enforcement, base-path helpers, and refreshed docs, assets, and tests to match the current static-export flow.

Testing

  • npm run check
  • npm run test:e2e
  • git diff --check
  • Browser verification with Playwright against the built preview, including homepage, generated links, and ARX2 rendering

Summary by CodeRabbit

  • New Features

    • Added arx3 codec support, native raw-source view, theme pre-hydration, improved viewer (raw toggle, copy/download, sample links), and serving of pre-compressed .br assets.
  • Bug Fixes

    • Robust basePath normalization and preview auto-detection; corrected static asset response headers for Brotli-compressed files.
  • Documentation

    • Updated architecture, payload/codec, deployment, testing, and dependency docs to reflect changes.
  • Tests

    • Expanded unit, integration, and E2E coverage for renderers, codecs, base-path, and static assets.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 12, 2026

📝 Walkthrough

Walkthrough

Adds arx3 and arx2 dictionary .br support, refactors ARX compressors/decoders and fragment selection, rewrites renderers and ViewerShell for lazy client rendering and readiness, adds base-path helpers, improves static/selfhosted serving and headers, adds asset compression and build-budget scripts, and expands tests and docs.

Changes

Transport, renderers, hosting, and tests

Layer / File(s) Summary
ARX codec, dictionary, and bench updates
src/lib/payload/arx-codec.ts, src/lib/payload/fragment-arx.ts, scripts/bench-codecs.mjs, scripts/bench-baseline.json, public/arx2-dictionary.json
Adds arx3, refactors trie/substitution handling, dictionary fetch prefers .br then JSON, adds arxCompressPayloads/arx3 decode, and updates benchmarks to include visibleChars and arx3.
Fragment construction and wire-format
src/lib/payload/fragment.ts, src/lib/payload/wire-format.ts, src/lib/payload/envelope.ts, src/lib/payload/link-creator.ts, src/lib/payload/schema.ts
Delegates ARX candidate building to fragment-arx dynamic imports, adds getVisibleFragmentLength, improves base64url performance, preallocates pack/unpack loops, normalizes single-artifact envelopes, expands codec list to include arx3 and validates codecs via Sets.
Viewer shell + ArtifactStage
src/components/viewer-shell.tsx, src/components/viewer/artifact-stage.tsx
ViewerShell lazy-loads client modules, decodes with cancellation and injected-payload skip, uses request-id guards for async encode results; ArtifactStage added with artifact-first UI, copy/download, raw/render toggles, and renderer-ready wiring.
Renderers (code/json/markdown/diff/csv/mermaid)
src/components/renderers/*
Refactors readiness and dynamic loading: CodeRenderer uses language cache and resolved theme with bracket decorations; JsonRenderer uses inline raw source; MarkdownRenderer coordinates fenced-block readiness; DiffRenderer lazy-loads diff lib and stylesheet with refcounting and rich/fallback paths; CsvRenderer renders native tables (removed react-table); Mermaid uses cached dynamic import.
Theme, layout, and styling
src/components/theme/*, src/app/layout.tsx, src/app/globals.css, src/components/theme/use-theme-controller.ts
Removes ThemeProvider module, adds useThemeController/useResolvedTheme, injects pre-hydration themeInitScript, updates ThemeToggle to controller, removes diff vendor import from globals and adds raw-source styles & scroll-margin.
Base-path and Next config
src/lib/site/base-path.ts, src/lib/site/canonical-base.ts, next.config.ts, next-env.d.ts, tests/base-path.test.ts
Adds normalizeBasePath/getConfiguredBasePath/withBasePath, replaces local basePath logic with helper, normalizes NEXT_PUBLIC_BASE_PATH handling, and includes routes.d.ts reference; tests validate behavior.
Static export & self-hosted server
scripts/serve-export.mjs, selfhosted/server.ts, public/_headers, public/vendor/diff-view-pure.css
serve-export now precomputes staticFiles, applies basePath from manifest, adds .br support and Content-Length/Cache-Control; selfhosted server mirrors .br headers and wasm MIME; vendored diff CSS added and compressed with accompanying headers.
Scripts & CI
scripts/compress-dictionary.mjs, scripts/check-build-budgets.mjs, scripts/ensure-next-types.mjs, scripts/clean-build-output.mjs, package.json, playwright.config.ts
compress-dictionary now emits arx2 and vendor .br outputs, check-build-budgets verifies gzipped bundle budgets, ensure-next-types generates .next type stubs, CI scripts updated to include build-budget checks and NO_COLOR handling.
Utilities & small libs
src/lib/code/language.ts, src/lib/utils.ts
Language-support caching with alias normalization; cn simplified to manual join (removed clsx/tailwind-merge).
Tests & e2e
tests/*, tests/e2e/viewer.spec.ts
Extensive new/updated tests covering arx2/arx3 dictionary fetching, encoder/decoder paths, renderer readiness and raw views, diff stylesheet asset checks, viewer-shell artifact selection races, base-path behavior, and many component/unit suites.
Docs & config updates
README.md, docs/*, docs/deployment.md, docs/payload-format.md, docs/testing.md, docs/architecture.md, docs/dependency-notes.md, eslint.config.mjs, tsconfig.json
Docs updated for arx3, .br-first dictionary behavior, renderer wording, preview server base-path auto-detection; testing commands and CI build-budget checks documented; ESLint ignores expanded; tsconfig enables noUnusedLocals/noUnusedParameters.

Sequence Diagram

sequenceDiagram
  participant Browser
  participant ViewerShell
  participant FragmentModule
  participant ArtifactStage
  participant StaticServer
  Browser->>ViewerShell: location.hash change / initial payload
  ViewerShell->>FragmentModule: decodeFragmentAsync(hash, options)
  FragmentModule-->>ViewerShell: { envelope, activeArtifact } or error
  ViewerShell->>ArtifactStage: render(activeArtifact) (dynamic import)
  ArtifactStage->>ArtifactStage: load renderer (code/json/markdown/diff/csv)
  ArtifactStage-->>ViewerShell: onRendererReady(readyKey)
  Browser->>StaticServer: GET /arx-dictionary.json.br or /vendor/diff-view-pure.css.br
  StaticServer-->>Browser: precompressed asset (Content-Encoding: br)
Loading

Estimated code review effort:
🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs:

Poem:

"A rabbit packed dictionaries tight and light,
Brotli hums through bytey night.
Links hop shorter, renderers wake,
Tiny hops make fragments bake.
Hooray — compressed and ready to bite!"

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/optimize-codebase

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 12, 2026

Deploying agent-render with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2a2cd0f
Status: ✅  Deploy successful!
Preview URL: https://d30c41c0.agent-render.pages.dev
Branch Preview URL: https://codex-optimize-codebase.agent-render.pages.dev

View logs

@baanish baanish marked this pull request as ready for review May 12, 2026 14:35
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3b61cac405

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/viewer-shell.tsx Outdated
Comment on lines +114 to +115
fragmentModulePromise ??= import("@/lib/payload/fragment");
return fragmentModulePromise;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset cached fragment import promise on load failure

If the dynamic import of @/lib/payload/fragment fails once (for example due to a transient chunk/network error), fragmentModulePromise stays set to that rejected promise forever, so every later decode/encode attempt in the same session immediately fails even after connectivity recovers. This turns a temporary asset-load issue into a persistent viewer/link-creator outage until full page reload; clear the cache when the import rejects so retries can succeed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/viewer-shell.tsx (1)

201-204: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear injected mode on internal navigation.

After a UUID-injected load, setFragmentHash() and handleGoHome() update hash without a hashchange event, so injectedPayloadRef.current stays true. Every later decode in that session then keeps using { skipFragmentBudget: true }, which silently disables the normal fragment budget.

🔧 Suggested fix
   const setFragmentHash = useCallback((nextHash: string) => {
+    injectedPayloadRef.current = false;
     if (window.location.hash === nextHash) {
       return;
     }

     window.history.replaceState(null, "", nextHash);
     setHash(nextHash);
   }, []);

   const handleGoHome = useCallback(() => {
+    injectedPayloadRef.current = false;
     const url = window.location.pathname + (window.location.search || "");
     window.history.replaceState(null, "", url);
     setHash("");
   }, []);

Also applies to: 233-235, 297-310

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/viewer-shell.tsx` around lines 201 - 204, The
injectedPayloadRef flag must be cleared whenever the app updates the location
hash internally so subsequent decodes don't keep using skipFragmentBudget;
update the routines that mutate hash — specifically syncHash, setFragmentHash,
handleGoHome and the similar hash-update handlers around the other occurrences
(the blocks at the locations analogous to 233-235 and 297-310) to set
injectedPayloadRef.current = false immediately before calling
setHash(window.location.hash) (or before updating the hash state) so injected
mode is cleared on internal navigation.
🧹 Nitpick comments (9)
tests/envelope.test.ts (1)

13-25: ⚡ Quick win

Add a regression test for empty bundles.

This PR now rejects artifacts.length === 0, but that new path is still untested here.

Suggested test addition
 describe("normalizeEnvelope", () => {
   it("normalizes single-artifact bundles to the only artifact id", () => {
@@
     expect(result.envelope.activeArtifactId).toBe("one");
   });
+
+  it("rejects bundles with no artifacts", () => {
+    const result = normalizeEnvelope({
+      ...baseEnvelope,
+      artifacts: [],
+    });
+
+    expect(result.ok).toBe(false);
+    if (result.ok) {
+      return;
+    }
+    expect(result.message).toMatch(/at least one artifact/i);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/envelope.test.ts` around lines 13 - 25, Add a regression test that
exercises the new code path rejecting envelopes with zero artifacts: create a
variant of baseEnvelope where artifacts is an empty array and call
normalizeEnvelope; assert the result is a failure (result.ok is false) and
optionally assert the error or rejection reason exists. Place this alongside the
existing tests (e.g., near the "normalizes single-artifact bundles..." test) and
reference normalizeEnvelope and baseEnvelope so the CI covers the new
artifacts.length === 0 branch.
scripts/bench-codecs.mjs (2)

176-179: 💤 Low value

Note: median mutates the input.

values.sort(...) sorts in place. Callers (measure, line 189) pass samples and then return { result, ms: median(samples) } without using samples again, so it's safe today—but future readers may be surprised. A defensive [...values].sort(...) is a one-line guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bench-codecs.mjs` around lines 176 - 179, The median function
currently mutates its input by calling values.sort(...); update median to sort a
shallow copy instead (e.g., use [...values] or Array.from(values]) before sort)
so callers like measure (which passes samples) are not affected by in-place
mutation; keep the rest of median logic the same and return 0 for empty arrays
as before.

211-219: 💤 Low value

Minor: repeatedFixture may slice mid-character for multi-byte input.

fixture.slice(0, targetLength) operates on JavaScript string units (UTF-16 code units), not bytes. With ASCII fixtures this is fine, but if anyone passes a block with multi-byte/emoji content the slice could split a surrogate pair. Not an issue with current fixtures—just flagging for future use.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bench-codecs.mjs` around lines 211 - 219, The current repeatedFixture
builds a string then uses fixture.slice(0, targetLength), which can split UTF-16
surrogate pairs for multi-byte characters; update repeatedFixture to trim by
Unicode code points instead of UTF-16 code units — e.g., replace the final slice
with a safe truncation using Array.from(fixture) (or [...fixture]) and join the
first targetLength code points (Array.from(fixture).slice(0,
targetLength).join('')) so block, segmentSuffix and index logic remain unchanged
but surrogate pairs/emoji won't be sliced in half.
src/components/home/link-creator.tsx (1)

36-79: 💤 Low value

Consider co-locating constants in a module helper if reuse grows.

fieldHints, fieldPlaceholders, codecOptions, defaultLinkCreatorDraft, and getBodyFieldLabel are now owned by this component. If the same draft/labels logic is ever needed by another surface (e.g., a docs preview or the viewer shell), pulling them into a small link-creator-presets.ts would avoid drift. Not required for this PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/home/link-creator.tsx` around lines 36 - 79, The constants and
small helpers used only in this component—fieldHints, fieldPlaceholders,
codecOptions, defaultLinkCreatorDraft, and getBodyFieldLabel—should be moved
into a shared module so they can be reused and kept in sync; create a new module
(e.g., link-creator-presets) exporting those symbols, update
src/components/home/link-creator.tsx to import fieldHints, fieldPlaceholders,
codecOptions, defaultLinkCreatorDraft, and getBodyFieldLabel from that module,
and ensure any types (ArtifactKind, LinkCreatorDraft) remain imported or
re-exported as needed so behavior of the existing functions (getBaseUrl,
numberFormatter, etc.) is unchanged.
scripts/serve-export.mjs (2)

60-92: 💤 Low value

Consider extending Brotli handling to other pre-compressed assets.

The current logic only flags .json.br and .css.br as Brotli-encoded. If the build ever emits other .br variants (e.g., .js.br, .svg.br, .html.br), they'll be served with Content-Type: application/octet-stream and no Content-Encoding: br, breaking the client. A general .br handler that derives the underlying MIME from the inner extension would be safer.

♻️ Proposed generalization
 function contentTypeFor(filePath) {
   if (filePath.endsWith(`${path.sep}.well-known${path.sep}api-catalog`)) {
     return apiCatalogContentType;
   }

-  if (filePath.endsWith(".json.br")) {
-    return "application/json; charset=utf-8";
-  }
-
-  if (filePath.endsWith(".css.br")) {
-    return "text/css; charset=utf-8";
-  }
+  if (filePath.endsWith(".br")) {
+    const innerExt = path.extname(filePath.slice(0, -3));
+    return contentTypes.get(innerExt) || "application/octet-stream";
+  }

   return contentTypes.get(path.extname(filePath)) || "application/octet-stream";
 }
-  if (filePath.endsWith(".json.br") || filePath.endsWith(".css.br")) {
+  if (filePath.endsWith(".br")) {
     headers["Content-Encoding"] = "br";
     headers.Vary = "Accept-Encoding";
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/serve-export.mjs` around lines 60 - 92, The code only treats .json.br
and .css.br as Brotli files; update contentTypeFor(filePath) and
headersFor(filePath) to handle any ".br" suffix: detect if
filePath.endsWith(".br"), derive the real ext by removing the ".br" suffix and
lookup the MIME via contentTypes.get(path.extname(realPath)) (falling back to
"application/octet-stream"), return that as the Content-Type in contentTypeFor
(and in headersFor set "Content-Encoding": "br" and "Vary": "Accept-Encoding"
whenever a ".br" file is detected), preserving existing logic for
isNextStaticAsset(filePath) and the api catalog Link header in headersFor.

159-178: 💤 Low value

Consider adding Content-Length for HEAD/GET responses.

stat() is already called and yields details.size, but it's discarded. Setting Content-Length lets clients (and CI HEAD checks for .br assets) verify byte counts and avoids chunked-transfer mode, which can confuse some tooling and proxies. Worth doing here since this is the preview/self-hosted entrypoint validated in the PR's tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/serve-export.mjs` around lines 159 - 178, The responses currently
send headers from headersFor(finalPath) but omit Content-Length even though a
prior stat() call yields details.size; update the handler to call fs.statSync
(or reuse the existing stat result) for finalPath and add a "Content-Length"
header with details.size to the headers passed to response.writeHead (for both
GET and HEAD paths), ensuring the HEAD branch still ends after sending headers
and the GET branch streams via createReadStream(finalPath); reference the
symbols finalPath, headersFor, response.writeHead, method, and createReadStream
when making the change.
src/components/renderers/diff-renderer.tsx (2)

198-201: 💤 Low value

Edge case: module-level stylesheet state isn't reference-counted across instances.

diffViewStylesheetPromise and the <link id="agent-render-diff-view-styles"> element are module globals. If two DiffRenderer instances coexist (e.g., a future split layout, or a stale rich instance + a newly mounted fallback during transitions), the fallback effect at line 624 will call removeDiffViewStylesheet() and strip the stylesheet that another rich instance is still using.

Today the viewer shell renders one artifact at a time, so this is not user-visible. If multi-diff layouts ever land, consider reference-counting active rich instances before removing the link.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/renderers/diff-renderer.tsx` around lines 198 - 201, The
stylesheet globals (diffViewStylesheetPromise and DIFF_VIEW_STYLESHEET_ID) are
not reference-counted, so removeDiffViewStylesheet() can remove the shared
<link> while another DiffRenderer still needs it; add a module-level counter
(e.g., diffViewInstanceCount) and update the lifecycle helpers so mounting a
rich renderer increments the count and unmounting decrements it, and change
removeDiffViewStylesheet() to only remove the DOM node and clear
diffViewStylesheetPromise when the count reaches 0; update the places that
previously called removeDiffViewStylesheet() (the effect in DiffRenderer that
tears down the fallback) to use the new increment/decrement semantics so the
stylesheet is retained while any instance remains.

621-642: ⚡ Quick win

Stylesheet load failure is silently swallowed.

loadDiffViewStylesheet().catch(() => undefined).then(() => setStylesReady(true)) marks styles "ready" even when both the .br and plain .css URLs fail. The rich diff then mounts unstyled with no console signal. Consider surfacing the error (e.g., falling back to the raw patch state, or at least console.warn) so production breakage is observable.

♻️ Proposed refinement
   setStylesReady(false);
   loadDiffViewStylesheet()
-    .catch(() => undefined)
-    .then(() => {
+    .then(() => {
       if (!cancelled) {
         setStylesReady(true);
       }
-    });
+    })
+    .catch((error) => {
+      if (!cancelled) {
+        console.warn("Failed to load rich diff stylesheet", error);
+        setStylesReady(true);
+      }
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/renderers/diff-renderer.tsx` around lines 621 - 642, The
current useEffect in diff-renderer.tsx swallows stylesheet load errors; update
the loadDiffViewStylesheet promise handling so failures are surfaced and you
don't silently mark styles as ready: call loadDiffViewStylesheet().then(() => {
if (!cancelled) setStylesReady(true) }).catch((err) => { console.warn('Failed to
load diff stylesheet', err); if (!cancelled) { removeDiffViewStylesheet(); /*
fall back to raw/unstyled diff */ setStylesReady(true); } }); keep the cancelled
guard and the existing checks around renderedDiff, and reference renderedDiff,
loadDiffViewStylesheet, removeDiffViewStylesheet, setStylesReady and
diffFilesHaveRenderableFile to locate the code.
src/lib/diff/git-patch.ts (1)

48-74: 💤 Low value

Minor: leading content before first diff --git is silently dropped.

In parsePatchSections, if the patch contains preamble text before the first diff --git header (e.g., a From ... mbox-style header from git format-patch), that prefix is skipped because the loop only starts accumulating after the first match. Today’s callers (UI/test fixtures) always feed plain diff --git bundles so this is not user-facing, but it’s worth noting as a known limitation if patches from git format-patch are ever piped in.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/diff/git-patch.ts` around lines 48 - 74, parsePatchSections currently
drops any leading preamble before the first DIFF_SECTION_HEADER_RE match (e.g.,
mbox "From ..." headers); update the loop in parsePatchSections so when the
first match has match.index > 0 you call parsePatchSection(normalized.slice(0,
match.index).trim(), sectionIndex) (using parsePatchSection and the current
sectionIndex), increment sectionIndex, and then set previousStart = match.index;
keep the existing logic for subsequent matches and the final push so all text
before the first "diff --git" is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Around line 95-99: Add a short explanatory comment for the "overrides" entry
that documents why "next" forces "postcss": "8.5.14" — e.g., note that Tailwind
CSS v4 requires postcss ^8.5.6 while Next.js ships postcss 8.4.31, and pinning
to 8.5.14 bridges that gap; place this comment adjacent to the overrides block
(the "overrides" -> "next" -> "postcss" entry) in package.json or add the same
note to docs/dependency-notes.md so future maintainers understand the
constraint.

In `@public/vendor/diff-view-pure.css`:
- Line 138: The vendored CSS triggers stylelint errors (e.g.,
declaration-empty-line-before and keyword-case) for lines like the rule
containing "border-spacing: var(--tw-border-spacing-x)
var(--tw-border-spacing-y);"—either normalize the file to comply with our lint
rules (fix empty-line-before formatting around declarations and normalize
keyword casing to lowercase across the affected selectors/rules) or exclude the
vendored files from linting by adding "public/vendor/**" to the stylelint ignore
pattern in the Stylelint config; update only the code around the offending
declarations or the stylelint config accordingly and ensure the change
references the same symbols (the border-spacing declaration and the listed
offending rules) so CI stops failing.

In `@src/components/renderers/markdown-renderer.tsx`:
- Around line 47-73: The getCodeLanguage function strips non-alphanumeric
characters so languages like "c++" and "c#" are reduced to "c"; update
getCodeLanguage (and its use of LANGUAGE_CLASS_PREFIX) to allow additional valid
characters such as '+' and '#' (and optionally '.' or other common alias chars)
when scanning the language token, so the loop does not break on those characters
and the returned slice preserves them (then still lower-case it and fallback to
"text" when empty).

In `@src/components/theme/use-theme-controller.ts`:
- Around line 73-125: Add a JSDoc block immediately above the exported
useThemeController function that documents what the hook does, its behavior, and
its return shape; include a short description, the fact that it reads/writes
theme via localStorage and syncs with system and cross-tab events, and an
`@returns` entry describing the returned object properties (preference,
resolvedTheme, setTheme) and their types/meanings so consumers and editors get
proper intellisense for useThemeController.

In `@src/components/viewer-shell.tsx`:
- Around line 258-263: The selection should be local-first: restore and use a
local state activeArtifactId in viewer-shell.tsx to control which artifact opens
immediately (set activeArtifactId when the user selects an artifact), derive
activeArtifact from that id (fallback to getActiveArtifact(envelope) only if
activeArtifactId is null), and avoid waiting for
encodeEnvelopeAsync()/parsed/hash round-trip to update the visible selection;
keep rendererReadyKey = activeArtifact ? hash : "" or use hash only for renderer
syncing but do not gate the UI selection on encodeEnvelopeAsync completion;
update any logic in functions/components referencing activeArtifact and
rendererReadyKey so they read from activeArtifactId-first and only reconcile
with parsed/hash results when encoding completes.

In `@src/lib/payload/arx-codec.ts`:
- Around line 499-518: In envelopeToArx2Tuple, the activeIndex check uses
`activeIndex > 0` which treats index 0 as absent; change the condition to
include zero (e.g., `activeIndex >= 0 ? activeIndex : undefined`) so the
activeArtifactId that maps to the first artifact is preserved in the tuple;
update the return that calls trimOptionalTuple ([2, artifacts, envelope.title,
activeIndex ...]) accordingly and keep the artifact loop and activeIndex
assignment (artifact.id === activeArtifactId) as-is.

In `@tests/arx-codec.test.ts`:
- Around line 543-559: The test currently only calls loadArxDictionary and never
verifies the arx2 overlay is fetched separately; update the test to also call
loadArx2OverlayDictionary (after stubbing fetch) so both URLs are requested, and
assert requests includes both "/arx-dictionary.json" and the overlay path (e.g.,
the arx2 overlay URL) in the correct order or as separate entries; keep the
vi.unstubAllGlobals() and loadArxDictionarySync(arxDictionaryJson) in finally as
before. Ensure you reference loadArxDictionary, loadArx2OverlayDictionary, and
loadArxDictionarySync when adding the second call and the new assertion.

In `@tests/components/link-creator.test.tsx`:
- Around line 72-79: The test is using the wrong element type for getByLabelText
— change the generic from HTMLInputElement to HTMLTextAreaElement for the
"Generated agent-render link" queries in link-creator.test.tsx (the lines
calling screen.getByLabelText<...>("Generated agent-render link").value and the
subsequent assertions) so the type matches the component's <textarea
aria-label="Generated agent-render link">; update all occurrences of that
generic to HTMLTextAreaElement.

---

Outside diff comments:
In `@src/components/viewer-shell.tsx`:
- Around line 201-204: The injectedPayloadRef flag must be cleared whenever the
app updates the location hash internally so subsequent decodes don't keep using
skipFragmentBudget; update the routines that mutate hash — specifically
syncHash, setFragmentHash, handleGoHome and the similar hash-update handlers
around the other occurrences (the blocks at the locations analogous to 233-235
and 297-310) to set injectedPayloadRef.current = false immediately before
calling setHash(window.location.hash) (or before updating the hash state) so
injected mode is cleared on internal navigation.

---

Nitpick comments:
In `@scripts/bench-codecs.mjs`:
- Around line 176-179: The median function currently mutates its input by
calling values.sort(...); update median to sort a shallow copy instead (e.g.,
use [...values] or Array.from(values]) before sort) so callers like measure
(which passes samples) are not affected by in-place mutation; keep the rest of
median logic the same and return 0 for empty arrays as before.
- Around line 211-219: The current repeatedFixture builds a string then uses
fixture.slice(0, targetLength), which can split UTF-16 surrogate pairs for
multi-byte characters; update repeatedFixture to trim by Unicode code points
instead of UTF-16 code units — e.g., replace the final slice with a safe
truncation using Array.from(fixture) (or [...fixture]) and join the first
targetLength code points (Array.from(fixture).slice(0, targetLength).join(''))
so block, segmentSuffix and index logic remain unchanged but surrogate
pairs/emoji won't be sliced in half.

In `@scripts/serve-export.mjs`:
- Around line 60-92: The code only treats .json.br and .css.br as Brotli files;
update contentTypeFor(filePath) and headersFor(filePath) to handle any ".br"
suffix: detect if filePath.endsWith(".br"), derive the real ext by removing the
".br" suffix and lookup the MIME via contentTypes.get(path.extname(realPath))
(falling back to "application/octet-stream"), return that as the Content-Type in
contentTypeFor (and in headersFor set "Content-Encoding": "br" and "Vary":
"Accept-Encoding" whenever a ".br" file is detected), preserving existing logic
for isNextStaticAsset(filePath) and the api catalog Link header in headersFor.
- Around line 159-178: The responses currently send headers from
headersFor(finalPath) but omit Content-Length even though a prior stat() call
yields details.size; update the handler to call fs.statSync (or reuse the
existing stat result) for finalPath and add a "Content-Length" header with
details.size to the headers passed to response.writeHead (for both GET and HEAD
paths), ensuring the HEAD branch still ends after sending headers and the GET
branch streams via createReadStream(finalPath); reference the symbols finalPath,
headersFor, response.writeHead, method, and createReadStream when making the
change.

In `@src/components/home/link-creator.tsx`:
- Around line 36-79: The constants and small helpers used only in this
component—fieldHints, fieldPlaceholders, codecOptions, defaultLinkCreatorDraft,
and getBodyFieldLabel—should be moved into a shared module so they can be reused
and kept in sync; create a new module (e.g., link-creator-presets) exporting
those symbols, update src/components/home/link-creator.tsx to import fieldHints,
fieldPlaceholders, codecOptions, defaultLinkCreatorDraft, and getBodyFieldLabel
from that module, and ensure any types (ArtifactKind, LinkCreatorDraft) remain
imported or re-exported as needed so behavior of the existing functions
(getBaseUrl, numberFormatter, etc.) is unchanged.

In `@src/components/renderers/diff-renderer.tsx`:
- Around line 198-201: The stylesheet globals (diffViewStylesheetPromise and
DIFF_VIEW_STYLESHEET_ID) are not reference-counted, so
removeDiffViewStylesheet() can remove the shared <link> while another
DiffRenderer still needs it; add a module-level counter (e.g.,
diffViewInstanceCount) and update the lifecycle helpers so mounting a rich
renderer increments the count and unmounting decrements it, and change
removeDiffViewStylesheet() to only remove the DOM node and clear
diffViewStylesheetPromise when the count reaches 0; update the places that
previously called removeDiffViewStylesheet() (the effect in DiffRenderer that
tears down the fallback) to use the new increment/decrement semantics so the
stylesheet is retained while any instance remains.
- Around line 621-642: The current useEffect in diff-renderer.tsx swallows
stylesheet load errors; update the loadDiffViewStylesheet promise handling so
failures are surfaced and you don't silently mark styles as ready: call
loadDiffViewStylesheet().then(() => { if (!cancelled) setStylesReady(true)
}).catch((err) => { console.warn('Failed to load diff stylesheet', err); if
(!cancelled) { removeDiffViewStylesheet(); /* fall back to raw/unstyled diff */
setStylesReady(true); } }); keep the cancelled guard and the existing checks
around renderedDiff, and reference renderedDiff, loadDiffViewStylesheet,
removeDiffViewStylesheet, setStylesReady and diffFilesHaveRenderableFile to
locate the code.

In `@src/lib/diff/git-patch.ts`:
- Around line 48-74: parsePatchSections currently drops any leading preamble
before the first DIFF_SECTION_HEADER_RE match (e.g., mbox "From ..." headers);
update the loop in parsePatchSections so when the first match has match.index >
0 you call parsePatchSection(normalized.slice(0, match.index).trim(),
sectionIndex) (using parsePatchSection and the current sectionIndex), increment
sectionIndex, and then set previousStart = match.index; keep the existing logic
for subsequent matches and the final push so all text before the first "diff
--git" is preserved.

In `@tests/envelope.test.ts`:
- Around line 13-25: Add a regression test that exercises the new code path
rejecting envelopes with zero artifacts: create a variant of baseEnvelope where
artifacts is an empty array and call normalizeEnvelope; assert the result is a
failure (result.ok is false) and optionally assert the error or rejection reason
exists. Place this alongside the existing tests (e.g., near the "normalizes
single-artifact bundles..." test) and reference normalizeEnvelope and
baseEnvelope so the CI covers the new artifacts.length === 0 branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e70ead9-0bf9-4889-9914-f182ec60c6b6

📥 Commits

Reviewing files that changed from the base of the PR and between 4c63929 and 3b61cac.

⛔ Files ignored due to path filters (8)
  • package-lock.json is excluded by !**/package-lock.json
  • tests/e2e/visual.spec.ts-snapshots/code-light-chromium.png is excluded by !**/*.png
  • tests/e2e/visual.spec.ts-snapshots/csv-compact-light-chromium.png is excluded by !**/*.png
  • tests/e2e/visual.spec.ts-snapshots/diff-light-chromium.png is excluded by !**/*.png
  • tests/e2e/visual.spec.ts-snapshots/empty-state-light-chromium.png is excluded by !**/*.png
  • tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png is excluded by !**/*.png
  • tests/e2e/visual.spec.ts-snapshots/markdown-dark-chromium.png is excluded by !**/*.png
  • tests/e2e/visual.spec.ts-snapshots/markdown-light-chromium.png is excluded by !**/*.png
📒 Files selected for processing (81)
  • README.md
  • docs/architecture.md
  • docs/dependency-notes.md
  • docs/deployment.md
  • docs/payload-format.md
  • docs/testing.md
  • eslint.config.mjs
  • next-env.d.ts
  • next.config.ts
  • package.json
  • playwright.config.ts
  • public/_headers
  • public/arx2-dictionary.json
  • public/arx2-dictionary.json.br
  • public/vendor/diff-view-pure.css
  • public/vendor/diff-view-pure.css.br
  • scripts/bench-baseline.json
  • scripts/bench-codecs.mjs
  • scripts/check-build-budgets.mjs
  • scripts/clean-build-output.mjs
  • scripts/compress-dictionary.mjs
  • scripts/ensure-next-types.mjs
  • scripts/serve-export.mjs
  • selfhosted/server.ts
  • skills/agent-render-linking/SKILL.md
  • src/app/globals.css
  • src/app/layout.tsx
  • src/app/security/page.tsx
  • src/app/url-explainer/page.tsx
  • src/components/home/link-creator.tsx
  • src/components/home/sample-link-data.ts
  • src/components/home/sample-links.tsx
  • src/components/renderers/code-renderer.tsx
  • src/components/renderers/csv-renderer.tsx
  • src/components/renderers/diff-renderer.tsx
  • src/components/renderers/json-renderer.tsx
  • src/components/renderers/markdown-renderer.tsx
  • src/components/renderers/mermaid-block.tsx
  • src/components/theme-provider.tsx
  • src/components/theme-toggle.tsx
  • src/components/theme/use-theme-controller.ts
  • src/components/viewer-shell.tsx
  • src/components/viewer/artifact-selector.tsx
  • src/components/viewer/artifact-stage.tsx
  • src/components/viewer/fragment-details-disclosure.tsx
  • src/lib/code/language.ts
  • src/lib/diff/git-patch.ts
  • src/lib/payload/arx-codec.ts
  • src/lib/payload/envelope.ts
  • src/lib/payload/fragment-arx.ts
  • src/lib/payload/fragment.ts
  • src/lib/payload/link-creator.ts
  • src/lib/payload/schema.ts
  • src/lib/payload/wire-format.ts
  • src/lib/site/base-path.ts
  • src/lib/site/canonical-base.ts
  • src/lib/utils.ts
  • tests/arx-codec.test.ts
  • tests/base-path.test.ts
  • tests/code-language.test.ts
  • tests/components/code-renderer.test.tsx
  • tests/components/csv-renderer.test.tsx
  • tests/components/diff-renderer.test.tsx
  • tests/components/json-renderer.test.tsx
  • tests/components/link-creator.test.tsx
  • tests/components/markdown-renderer.test.tsx
  • tests/components/mermaid-block.test.tsx
  • tests/components/theme-toggle.test.tsx
  • tests/components/viewer-shell-artifact-select.test.tsx
  • tests/components/viewer-shell.test.tsx
  • tests/diff-style-asset.test.ts
  • tests/e2e/viewer.spec.ts
  • tests/envelope.test.ts
  • tests/fragment.test.ts
  • tests/git-patch.test.ts
  • tests/headers.test.ts
  • tests/sample-link-data.test.ts
  • tests/selfhosted/api-catalog.test.ts
  • tests/setup.tsx
  • tests/utils.test.ts
  • tsconfig.json
💤 Files with no reviewable changes (3)
  • src/components/viewer/fragment-details-disclosure.tsx
  • tests/setup.tsx
  • src/components/theme-provider.tsx

Comment thread package.json
.diff-tailwindcss-wrapper .border-spacing-0 {
--tw-border-spacing-x: 0px;
--tw-border-spacing-y: 0px;
border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stylelint violations in vendored CSS should be resolved or excluded from lint scope.

This file currently contains stylelint-reported violations (declaration-empty-line-before and keyword casing at Line 200). Please either normalize the file to match the active rules or exclude public/vendor/** from stylelint targeting to keep checks stable.

Also applies to: 145-145, 149-149, 153-153, 200-200, 252-252, 656-656

🧰 Tools
🪛 Stylelint (17.11.0)

[error] 138-138: Expected empty line before declaration (declaration-empty-line-before)

(declaration-empty-line-before)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/vendor/diff-view-pure.css` at line 138, The vendored CSS triggers
stylelint errors (e.g., declaration-empty-line-before and keyword-case) for
lines like the rule containing "border-spacing: var(--tw-border-spacing-x)
var(--tw-border-spacing-y);"—either normalize the file to comply with our lint
rules (fix empty-line-before formatting around declarations and normalize
keyword casing to lowercase across the affected selectors/rules) or exclude the
vendored files from linting by adding "public/vendor/**" to the stylelint ignore
pattern in the Stylelint config; update only the code around the offending
declarations or the stylelint config accordingly and ensure the change
references the same symbols (the border-spacing declaration and the listed
offending rules) so CI stops failing.

Comment thread src/components/renderers/markdown-renderer.tsx
Comment thread src/components/theme/use-theme-controller.ts
Comment thread src/components/viewer-shell.tsx
Comment thread src/lib/payload/arx-codec.ts
Comment thread tests/arx-codec.test.ts
Comment thread tests/components/link-creator.test.tsx Outdated
Comment thread scripts/serve-export.mjs Fixed
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/serve-export.mjs (1)

156-162: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid the unchecked stat() after existsSync().

This adds a TOCTOU window: if the file disappears or is replaced between those calls, the request handler throws instead of returning a normal 404.

Suggested fix
-  if (!existsSync(finalPath)) {
-    response.writeHead(404);
-    response.end("Not found");
-    return;
-  }
-
-  const finalDetails = await stat(finalPath);
+  let finalDetails;
+  try {
+    finalDetails = await stat(finalPath);
+  } catch {
+    response.writeHead(404);
+    response.end("Not found");
+    return;
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/serve-export.mjs` around lines 156 - 162, Replace the unsafe
existsSync() + stat() pattern with a single awaited stat() inside a try/catch:
call stat(finalPath) in a try block, on success verify finalDetails.isFile() (or
other required checks) and proceed, and on any error (ENOENT or other) catch it
and respond with response.writeHead(404); response.end("Not found"); return;
this eliminates the TOCTOU window and ensures missing or inaccessible files are
handled safely.
🧹 Nitpick comments (3)
tests/components/viewer-shell-artifact-select.test.tsx (1)

106-129: ⚡ Quick win

Consider more robust hash parsing in the mock.

The activeArtifactId derivation at line 112 uses simple string includes (hash.includes("three")), which is fragile. If hash formats evolve or if these substrings appear elsewhere, the mock could return unexpected artifacts. Consider using a more explicit pattern like splitting on delimiters or regex matching.

♻️ Example: More robust parsing
-    const activeArtifactId = hash.includes("three") ? "three" : hash.includes("two") ? "two" : "one";
+    // Extract artifact ID from hash pattern: agent-render=v1.plain.<artifactId>
+    const match = hash.match(/agent-render=v1\.plain\.(\w+)/);
+    const activeArtifactId = match?.[1] ?? "one";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/components/viewer-shell-artifact-select.test.tsx` around lines 106 -
129, The mock's activeArtifactId derivation in decodeFragmentAsync is fragile
because it uses hash.includes(...); change it to explicitly parse the artifact
id (e.g., use a regex like /(?:^|[:\-])(?:one|two|three)(?:$|[:\-])/, or split
the hash on a known delimiter and pick the token) so decodeFragmentAsync
deterministically maps hashes to "one", "two", or "three" and update any tests
expecting createEnvelope(activeArtifactId) accordingly; reference the
decodeFragmentAsync mock, fragmentMock.decodes, and createEnvelope to locate
where to apply this parsing fix.
tests/components/link-creator.test.tsx (2)

17-23: ⚡ Quick win

Consider adding error handling tests.

The mock includes a reject callback but no tests verify error handling behavior. Consider adding a test that rejects a generation request to ensure the component handles errors gracefully.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/components/link-creator.test.tsx` around lines 17 - 23, The mock for
createGeneratedArtifactLinkAsync exposes a reject callback via
generationMock.pending but no test exercises it; add a test that triggers
rejection (call the pending item's reject with an Error or rejection value)
after initiating generation and then assert the LinkCreator component's error
handling — e.g., that an error message is rendered or an error state callback is
invoked. Target the mocked function createGeneratedArtifactLinkAsync and the
generationMock.pending queue to perform the rejection, await any async UI
updates (e.g., using findByText or waitFor) and verify the expected error
UI/behavior, then clean up/reset mocks.

1-119: ⚡ Quick win

Consider error handling and visual state verification.

While E2E Playwright tests cover LinkCreator workflows (tests/e2e/viewer.spec.ts), the unit tests verify visual assertions (textarea content, warning message visibility) without corresponding Playwright coverage for those specific state changes. Additionally, error handling is not tested—the mock's reject function is never invoked, leaving error scenarios uncovered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/components/link-creator.test.tsx` around lines 1 - 119, Tests for
LinkCreator lack coverage for rejection/error flows and miss asserting visual
states after errors; update tests in link-creator.test.tsx to simulate and
assert those cases by using generationMock.pending[].reject(...) to trigger
createGeneratedArtifactLinkAsync failure and then verify UI reacts (e.g., an
error message, disabled/cleared Generated agent-render link textarea, and any
visual error state) and also add assertions after UI interactions (like clicking
format/quality buttons) that visual state remains consistent; reference
generationMock.pending, createGeneratedLink, and the LinkCreator component to
locate where to add a new it() block(s) that rejects pending promises and
asserts the expected DOM changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/serve-export.mjs`:
- Around line 97-103: The code slices the percent-encoded request URL (urlPath)
into cleanPath and assigns it to relativePath, so filesystem lookups will see
raw percent sequences; fix by decoding the path before resolving on disk:
compute cleanPath = urlPath.slice(0, pathEnd) and then set relativePath =
decodeURIComponent(cleanPath) (wrap decodeURIComponent in try/catch if you want
to handle malformed encodings). Apply the same decoding change to the other
occurrence referenced (lines around 121-122) so all file-resolution uses of
relativePath operate on a decoded path.
- Around line 91-94: The current isInsideOutputDirectory(filePath) check can be
bypassed via symlinks; update the check to resolve and compare real filesystem
targets instead of raw path strings (or alternatively reject any symlink segment
while walking the path). Specifically, in isInsideOutputDirectory and the
similar logic around the other export checks (the block at lines 122-128), call
fs.promises.realpath() (or synchronous realpath if appropriate) on both
outputDirectory and the resolved filePath and then ensure the file's real path
startsWith the outputDirectory real path; or walk the filePath with fs.lstat()
and fail if any path segment is a symbolic link before performing
stat/createReadStream. Ensure you reference and update isInsideOutputDirectory
and the corresponding export validation block to prevent symlink escape.

In `@src/components/renderers/diff-renderer.tsx`:
- Around line 20-23: Remove the Brotli sidecar entry from the stylesheet list:
update the diffViewStylesheetHrefs array (used where withBasePath is called) to
omit "/vendor/diff-view-pure.css.br" and only include the actual stylesheet path
("/vendor/diff-view-pure.css") so the renderer uses standard content-negotiated
CSS rather than attempting to load raw .br bytes as a stylesheet.

In `@src/components/renderers/markdown-renderer.tsx`:
- Around line 190-208: The markBlockReady callback currently reassigns
readyKeyRef.current and clears readyBlockIdsRef when it detects a mismatch,
which adopts a stale readyKey; instead, detect when readyKeyRef.current !==
readyKey and simply ignore/drop the callback by returning early. Update
markBlockReady (the function using readyKeyRef, readyKey, readyBlockIdsRef,
reportedReadyKeyRef, setReadyBlockState, embeddedBlockCount, and reportReady) so
it does not mutate refs/state on mismatch — just return — and keep the rest of
the logic (adding blockId, updating setReadyBlockState, and calling reportReady)
unchanged.
- Around line 35-50: The readiness counter undercounts fenced code blocks
because countEmbeddedCodeBlocks() uses EMBEDDED_CODE_BLOCK_PATTERN that
disallows up-to-3-space indented fences; replace the raw-regex counting approach
with deriving the embeddedBlockCount from actual rendered nodes instead: remove
or stop using EMBEDDED_CODE_BLOCK_PATTERN and instead increment the ready target
when ReactMarkdown/remarkGfm produces a code/mermaid/embedded block (e.g.,
inside the render path where EmbeddedCodeRenderer and MermaidBlock are
instantiated), then keep markBlockReady() logic unchanged so nextCount compares
against the real rendered block count rather than a fragile regex result.

In `@src/lib/diff/git-patch.ts`:
- Around line 64-69: The code currently converts leading preamble text into a
ParsedPatchFile by calling parsePatchSection(preamble, sectionIndex) and pushing
it into files; instead, do not promote preamble into the files array. Change the
branch that handles a non-empty preamble (the block using match.index,
normalized, preamble, parsePatchSection, and sectionIndex) to either discard the
preamble or collect it in a separate variable/array (e.g., preambleText or
preambleSections) for non-file metadata, and remove the files.push(...) call and
the sectionIndex increment so files remains strictly file-only.

In `@tests/components/markdown-renderer.test.tsx`:
- Around line 62-125: Add a real-browser Playwright test that exercises the
fenced-code presentation path instead of relying solely on mocks: create a new
Playwright test that mounts the MarkdownRenderer (or navigates to a page that
renders it) with a fenced code sample (e.g., "```ts\nconst a = 1;\n```") and
assert the visible language chip text ("ts") and the rendered code content
("const a = 1") in the browser DOM; reference the component name
MarkdownRenderer and the existing unit test cases in
tests/components/markdown-renderer.test.tsx as the examples to mirror, and place
the new test alongside other Playwright/e2e tests so CI will run it.

---

Outside diff comments:
In `@scripts/serve-export.mjs`:
- Around line 156-162: Replace the unsafe existsSync() + stat() pattern with a
single awaited stat() inside a try/catch: call stat(finalPath) in a try block,
on success verify finalDetails.isFile() (or other required checks) and proceed,
and on any error (ENOENT or other) catch it and respond with
response.writeHead(404); response.end("Not found"); return; this eliminates the
TOCTOU window and ensures missing or inaccessible files are handled safely.

---

Nitpick comments:
In `@tests/components/link-creator.test.tsx`:
- Around line 17-23: The mock for createGeneratedArtifactLinkAsync exposes a
reject callback via generationMock.pending but no test exercises it; add a test
that triggers rejection (call the pending item's reject with an Error or
rejection value) after initiating generation and then assert the LinkCreator
component's error handling — e.g., that an error message is rendered or an error
state callback is invoked. Target the mocked function
createGeneratedArtifactLinkAsync and the generationMock.pending queue to perform
the rejection, await any async UI updates (e.g., using findByText or waitFor)
and verify the expected error UI/behavior, then clean up/reset mocks.
- Around line 1-119: Tests for LinkCreator lack coverage for rejection/error
flows and miss asserting visual states after errors; update tests in
link-creator.test.tsx to simulate and assert those cases by using
generationMock.pending[].reject(...) to trigger createGeneratedArtifactLinkAsync
failure and then verify UI reacts (e.g., an error message, disabled/cleared
Generated agent-render link textarea, and any visual error state) and also add
assertions after UI interactions (like clicking format/quality buttons) that
visual state remains consistent; reference generationMock.pending,
createGeneratedLink, and the LinkCreator component to locate where to add a new
it() block(s) that rejects pending promises and asserts the expected DOM
changes.

In `@tests/components/viewer-shell-artifact-select.test.tsx`:
- Around line 106-129: The mock's activeArtifactId derivation in
decodeFragmentAsync is fragile because it uses hash.includes(...); change it to
explicitly parse the artifact id (e.g., use a regex like
/(?:^|[:\-])(?:one|two|three)(?:$|[:\-])/, or split the hash on a known
delimiter and pick the token) so decodeFragmentAsync deterministically maps
hashes to "one", "two", or "three" and update any tests expecting
createEnvelope(activeArtifactId) accordingly; reference the decodeFragmentAsync
mock, fragmentMock.decodes, and createEnvelope to locate where to apply this
parsing fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4722b931-85f1-43b5-a188-c9b3689c7907

📥 Commits

Reviewing files that changed from the base of the PR and between 3b61cac and 671cc85.

📒 Files selected for processing (14)
  • docs/dependency-notes.md
  • scripts/bench-codecs.mjs
  • scripts/serve-export.mjs
  • src/components/renderers/diff-renderer.tsx
  • src/components/renderers/markdown-renderer.tsx
  • src/components/theme/use-theme-controller.ts
  • src/components/viewer-shell.tsx
  • src/lib/diff/git-patch.ts
  • tests/arx-codec.test.ts
  • tests/components/link-creator.test.tsx
  • tests/components/markdown-renderer.test.tsx
  • tests/components/viewer-shell-artifact-select.test.tsx
  • tests/envelope.test.ts
  • tests/git-patch.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • tests/envelope.test.ts
  • src/components/theme/use-theme-controller.ts
  • tests/arx-codec.test.ts
  • src/components/viewer-shell.tsx
  • scripts/bench-codecs.mjs

Comment thread scripts/serve-export.mjs
Comment thread scripts/serve-export.mjs Outdated
Comment on lines 97 to 103
const queryIndex = urlPath.indexOf("?");
const hashIndex = urlPath.indexOf("#");
let pathEnd = urlPath.length;
if (queryIndex !== -1) pathEnd = Math.min(pathEnd, queryIndex);
if (hashIndex !== -1) pathEnd = Math.min(pathEnd, hashIndex);
const cleanPath = urlPath.slice(0, pathEnd);
let relativePath = cleanPath;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Decode the request path before resolving it on disk.

request.url is still percent-encoded here, so valid requests like /images/My%20File.png or non-ASCII route segments resolve to literal %20 / %C3... filenames and 404 in preview.

Suggested fix
-  const cleanPath = urlPath.slice(0, pathEnd);
+  let cleanPath;
+  try {
+    cleanPath = decodeURIComponent(urlPath.slice(0, pathEnd));
+  } catch {
+    return null;
+  }

Also applies to: 121-122

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/serve-export.mjs` around lines 97 - 103, The code slices the
percent-encoded request URL (urlPath) into cleanPath and assigns it to
relativePath, so filesystem lookups will see raw percent sequences; fix by
decoding the path before resolving on disk: compute cleanPath = urlPath.slice(0,
pathEnd) and then set relativePath = decodeURIComponent(cleanPath) (wrap
decodeURIComponent in try/catch if you want to handle malformed encodings).
Apply the same decoding change to the other occurrence referenced (lines around
121-122) so all file-resolution uses of relativePath operate on a decoded path.

Comment on lines +20 to +23
const diffViewStylesheetHrefs = [
withBasePath("/vendor/diff-view-pure.css.br"),
withBasePath("/vendor/diff-view-pure.css"),
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Do browsers automatically decompress a .css.brfile when it is linked via, or is Brotli only applied when the response is served with Content-Encoding: brfor the.css URL?

💡 Result:

Browsers do not decide “this is Brotli because the URL ends with.br (e.g. /styles.css.br)”. Brotli is only applied when the HTTP response indicates that the body is Brotli-compressed via the response header Content-Encoding: br (selected via Accept-Encoding negotiation). This is independent of how you name the file; the extension by itself doesn’t trigger decompression. When you link the browser will fetch that URL as a stylesheet. If the server returns the resource with Content-Encoding: br, the browser will decompress it to obtain the underlying CSS. If Content-Encoding: br is missing (or wrong), the browser will treat the bytes as-is and CSS parsing will fail (or you’ll get an invalid resource / decoding error). Content-Encoding is explicitly defined as telling the recipient how to decode the response body, and the client negotiates supported encodings with Accept-Encoding; the server then signals the chosen encoding with Content-Encoding.[1][2] Practical implication for precompressed static assets: servers commonly serve the precompressed.br variant only to clients that accept br, and they do so by rewriting to the.br file and setting Content-Encoding: br on the response.[3] So, to answer your question directly: - Yes: a browser can decompress a linked /styles.css.br file, but only because the response has Content-Encoding: br. - No: Brotli is not automatically inferred solely from the filename (.css.br) or the tag.

Citations:


🏁 Script executed:

rg -n "diffViewStylesheetHrefs" src/components/renderers/diff-renderer.tsx -A 5 -B 2

Repository: baanish/agent-render

Length of output: 655


🏁 Script executed:

rg -n "loadStylesheetHref" src/components/renderers/diff-renderer.tsx -A 10 -B 2

Repository: baanish/agent-render

Length of output: 910


🏁 Script executed:

sed -n '126,150p' src/components/renderers/diff-renderer.tsx

Repository: baanish/agent-render

Length of output: 831


Remove the Brotli sidecar from the stylesheet array.

Linking /vendor/diff-view-pure.css.br via <link rel="stylesheet"> requests the file directly without Content-Encoding: br headers. The server returns the file with HTTP 200, the browser's load event fires, and this prevents fallback to the actual stylesheet—leaving the rich diff unstyled while attempting to parse raw Brotli bytes as CSS.

Brotli decompression only occurs when the server explicitly sends Content-Encoding: br for the .css file; the .br filename extension alone has no effect. Rely on server-side content negotiation instead.

Suggested change
 const diffViewStylesheetHrefs = [
-  withBasePath("/vendor/diff-view-pure.css.br"),
   withBasePath("/vendor/diff-view-pure.css"),
 ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const diffViewStylesheetHrefs = [
withBasePath("/vendor/diff-view-pure.css.br"),
withBasePath("/vendor/diff-view-pure.css"),
];
const diffViewStylesheetHrefs = [
withBasePath("/vendor/diff-view-pure.css"),
];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/renderers/diff-renderer.tsx` around lines 20 - 23, Remove the
Brotli sidecar entry from the stylesheet list: update the
diffViewStylesheetHrefs array (used where withBasePath is called) to omit
"/vendor/diff-view-pure.css.br" and only include the actual stylesheet path
("/vendor/diff-view-pure.css") so the renderer uses standard content-negotiated
CSS rather than attempting to load raw .br bytes as a stylesheet.

Comment on lines +35 to +50
const EMBEDDED_CODE_BLOCK_PATTERN = /(?:^|\n)```[^\n]*\n[\s\S]*?\n```(?=\n|$)/g;
const LANGUAGE_CLASS_PREFIX = "language-";

type ReadyBlockState = {
key: string;
count: number;
};

function countEmbeddedCodeBlocks(content: string): number {
EMBEDDED_CODE_BLOCK_PATTERN.lastIndex = 0;
let count = 0;
while (EMBEDDED_CODE_BLOCK_PATTERN.exec(content)) {
count += 1;
}
return count;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

# First, locate the file and check its structure
find . -type f -name "markdown-renderer.tsx" 2>/dev/null

Repository: baanish/agent-render

Length of output: 113


🏁 Script executed:

# Read the markdown-renderer.tsx file
cat -n ./src/components/renderers/markdown-renderer.tsx

Repository: baanish/agent-render

Length of output: 10893


The readiness counter undercounts fenced blocks when the regex pattern is stricter than the parsed markdown format.

countEmbeddedCodeBlocks() uses a regex that requires zero indentation before the opening fence ((?:^|\n)```), but the GFM spec allows up to 3 spaces of indentation. ReactMarkdown with remarkGfm parses these indented fences, so blocks that match the spec but not the regex will still render and call markBlockReady()afterdata-renderer-readyhas already flipped to "true" (once the undercountednextCount >= embeddedBlockCount at line 205). This causes the ready state to signal completion while async renderers (EmbeddedCodeRenderer, MermaidBlock`) are still loading. Derive the ready count from the actual rendered blocks instead of re-parsing raw markdown with a narrower regex.

🧰 Tools
🪛 OpenGrep (1.20.0)

[ERROR] 46-46: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/renderers/markdown-renderer.tsx` around lines 35 - 50, The
readiness counter undercounts fenced code blocks because
countEmbeddedCodeBlocks() uses EMBEDDED_CODE_BLOCK_PATTERN that disallows
up-to-3-space indented fences; replace the raw-regex counting approach with
deriving the embeddedBlockCount from actual rendered nodes instead: remove or
stop using EMBEDDED_CODE_BLOCK_PATTERN and instead increment the ready target
when ReactMarkdown/remarkGfm produces a code/mermaid/embedded block (e.g.,
inside the render path where EmbeddedCodeRenderer and MermaidBlock are
instantiated), then keep markBlockReady() logic unchanged so nextCount compares
against the real rendered block count rather than a fragile regex result.

Comment on lines +190 to +208
const markBlockReady = useCallback((blockId: string) => {
if (readyKeyRef.current !== readyKey) {
readyKeyRef.current = readyKey;
readyBlockIdsRef.current.clear();
reportedReadyKeyRef.current = null;
}

if (readyBlockIdsRef.current.has(blockId)) {
return;
}

readyBlockIdsRef.current.add(blockId);
const nextCount = readyBlockIdsRef.current.size;
setReadyBlockState({ key: readyKey, count: nextCount });

if (nextCount >= embeddedBlockCount) {
reportReady(readyKey);
}
}, [embeddedBlockCount, readyKey, reportReady]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ignore stale block-ready callbacks instead of switching back to the old artifact key.

If an async child from the previous artifact.content resolves late, this mismatch branch rewrites the refs/state back to the stale readyKey and can report onReady for obsolete content. The stale callback should be dropped here, not adopted.

🔧 Suggested fix
   const markBlockReady = useCallback((blockId: string) => {
     if (readyKeyRef.current !== readyKey) {
-      readyKeyRef.current = readyKey;
-      readyBlockIdsRef.current.clear();
-      reportedReadyKeyRef.current = null;
+      return;
     }

     if (readyBlockIdsRef.current.has(blockId)) {
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const markBlockReady = useCallback((blockId: string) => {
if (readyKeyRef.current !== readyKey) {
readyKeyRef.current = readyKey;
readyBlockIdsRef.current.clear();
reportedReadyKeyRef.current = null;
}
if (readyBlockIdsRef.current.has(blockId)) {
return;
}
readyBlockIdsRef.current.add(blockId);
const nextCount = readyBlockIdsRef.current.size;
setReadyBlockState({ key: readyKey, count: nextCount });
if (nextCount >= embeddedBlockCount) {
reportReady(readyKey);
}
}, [embeddedBlockCount, readyKey, reportReady]);
const markBlockReady = useCallback((blockId: string) => {
if (readyKeyRef.current !== readyKey) {
return;
}
if (readyBlockIdsRef.current.has(blockId)) {
return;
}
readyBlockIdsRef.current.add(blockId);
const nextCount = readyBlockIdsRef.current.size;
setReadyBlockState({ key: readyKey, count: nextCount });
if (nextCount >= embeddedBlockCount) {
reportReady(readyKey);
}
}, [embeddedBlockCount, readyKey, reportReady]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/renderers/markdown-renderer.tsx` around lines 190 - 208, The
markBlockReady callback currently reassigns readyKeyRef.current and clears
readyBlockIdsRef when it detects a mismatch, which adopts a stale readyKey;
instead, detect when readyKeyRef.current !== readyKey and simply ignore/drop the
callback by returning early. Update markBlockReady (the function using
readyKeyRef, readyKey, readyBlockIdsRef, reportedReadyKeyRef,
setReadyBlockState, embeddedBlockCount, and reportReady) so it does not mutate
refs/state on mismatch — just return — and keep the rest of the logic (adding
blockId, updating setReadyBlockState, and calling reportReady) unchanged.

Comment thread src/lib/diff/git-patch.ts
Comment on lines +64 to +69
} else if (match.index > 0) {
const preamble = normalized.slice(0, match.index).trim();
if (preamble) {
files.push(parsePatchSection(preamble, sectionIndex));
sectionIndex += 1;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't turn patch preambles into synthetic file entries.

This branch promotes mail headers / other leading preamble text into a ParsedPatchFile, so downstream consumers treat it like a real diff file. That produces a bogus file-1 entry in the file nav and can also push non-file content into the rich diff path. Please keep preamble text separate from ParsedPatchFile[] (or drop it here) so the parser stays file-only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/diff/git-patch.ts` around lines 64 - 69, The code currently converts
leading preamble text into a ParsedPatchFile by calling
parsePatchSection(preamble, sectionIndex) and pushing it into files; instead, do
not promote preamble into the files array. Change the branch that handles a
non-empty preamble (the block using match.index, normalized, preamble,
parsePatchSection, and sectionIndex) to either discard the preamble or collect
it in a separate variable/array (e.g., preambleText or preambleSections) for
non-file metadata, and remove the files.push(...) call and the sectionIndex
increment so files remains strictly file-only.

Comment on lines +62 to +125
describe("MarkdownRenderer", () => {
it("renders language-less fenced code blocks with the text fallback language", async () => {
const { container } = render(<MarkdownRenderer artifact={createArtifact({ content: "```\nplain text\n```" })} />);

await waitFor(() => {
expect(
Array.from(container.querySelectorAll(".markdown-code-chip")).map((node) => node.textContent),
).toEqual(["text"]);
expect(container).toHaveTextContent("plain text");
});
});

it("renders every fenced code block with its language chip", async () => {
const { container } = render(
<MarkdownRenderer
artifact={createArtifact({
content: "```ts\nconst a = 1;\n```\n\n```json\n{\"ok\":true}\n```",
})}
/>,
);

await waitFor(() => {
expect(
Array.from(container.querySelectorAll(".markdown-code-chip")).map((node) => node.textContent),
).toEqual(["ts", "json"]);
expect(container).toHaveTextContent("const a = 1");
expect(container).toHaveTextContent('{"ok":true}');
});
});

it("preserves non-alphanumeric fenced code language ids", async () => {
const { container } = render(
<MarkdownRenderer
artifact={createArtifact({
content: "```c++\nint main() {}\n```\n\n```c#\nConsole.WriteLine();\n```",
})}
/>,
);

await waitFor(() => {
expect(
Array.from(container.querySelectorAll(".markdown-code-chip")).map((node) => node.textContent),
).toEqual(["c++", "c#"]);
});
});

it("does not wait forever on tilde blocks that are not mounted as embedded renderers", async () => {
const onReady = vi.fn();

render(
<MarkdownRenderer
artifact={createArtifact({
content: "~~~json\n{\"ok\":true}\n~~~",
})}
onReady={onReady}
/>,
);

await waitFor(() => {
expect(screen.getByTestId("renderer-markdown")).toHaveAttribute("data-renderer-ready", "true");
expect(onReady).toHaveBeenCalled();
});
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add one real-browser Playwright case for the fenced-code presentation path.

These assertions run entirely through mocks, so they won't catch regressions in the actual dynamic renderer/chip rendering path. Please mirror at least one fenced-code example in Playwright as well.

As per coding guidelines, "Verify visual changes with Playwright when they affect layout or renderer presentation in test files".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/components/markdown-renderer.test.tsx` around lines 62 - 125, Add a
real-browser Playwright test that exercises the fenced-code presentation path
instead of relying solely on mocks: create a new Playwright test that mounts the
MarkdownRenderer (or navigates to a page that renders it) with a fenced code
sample (e.g., "```ts\nconst a = 1;\n```") and assert the visible language chip
text ("ts") and the rendered code content ("const a = 1") in the browser DOM;
reference the component name MarkdownRenderer and the existing unit test cases
in tests/components/markdown-renderer.test.tsx as the examples to mirror, and
place the new test alongside other Playwright/e2e tests so CI will run it.

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 12, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednext@​15.1.11 ⏵ 15.5.1862 +1100 +7591 -19870
Updatedeslint-config-next@​15.1.11 ⏵ 15.5.1899 +110065 +198 +1100
Updatedmermaid@​11.14.0 ⏵ 11.15.094 +1100 +6100 +197100

View full report

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
scripts/serve-export.mjs (1)

146-153: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Decode the request path before route lookup.

Line 152 still keeps %xx escapes intact, so requests like /images/My%20File.png or non-ASCII filenames won't match the precomputed route map and will 404.

Suggested fix
-  const cleanPath = urlPath.slice(0, pathEnd);
+  let cleanPath;
+  try {
+    cleanPath = decodeURIComponent(urlPath.slice(0, pathEnd));
+  } catch {
+    return null;
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/serve-export.mjs` around lines 146 - 153, The toRoutePath function
currently slices the raw URL and leaves percent-escapes intact, causing
mismatches for encoded filenames; decode the cleaned path (cleanPath) using
decodeURIComponent before building routePath so `/images/My%20File.png` matches
the precomputed routes, and wrap the decode in a try/catch to fall back to the
original cleanPath if decodeURIComponent throws (handle malformed
percent-encodings), then ensure the decoded value still gets normalized to start
with a leading slash when assigning routePath.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/serve-export.mjs`:
- Around line 193-199: The code sends a 200 response before ensuring the file
stream is readable; change the flow so you do not call response.writeHead(200,
...) before the ReadStream opens and add an error handler on the stream: for
method === "HEAD" you can still call response.writeHead(200, headersFor(...))
and response.end() immediately, but for GET create the
ReadStream(staticFile.filePath), attach stream.once('open', () => {
response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
stream.pipe(response); }), and attach stream.on('error', err => {
response.destroy(); response.writeHead(err.code === 'ENOENT' ? 404 : 500);
response.end(); }) so missing/unreadable files don’t cause a broken 200 response
or crash.

---

Duplicate comments:
In `@scripts/serve-export.mjs`:
- Around line 146-153: The toRoutePath function currently slices the raw URL and
leaves percent-escapes intact, causing mismatches for encoded filenames; decode
the cleaned path (cleanPath) using decodeURIComponent before building routePath
so `/images/My%20File.png` matches the precomputed routes, and wrap the decode
in a try/catch to fall back to the original cleanPath if decodeURIComponent
throws (handle malformed percent-encodings), then ensure the decoded value still
gets normalized to start with a leading slash when assigning routePath.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d980e80b-513f-4f26-8432-77e1152f48df

📥 Commits

Reviewing files that changed from the base of the PR and between 671cc85 and e638be4.

📒 Files selected for processing (1)
  • scripts/serve-export.mjs

Comment thread scripts/serve-export.mjs
Comment on lines +193 to +199
response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
if (method === "HEAD") {
response.end();
return;
}

createReadStream(finalPath).pipe(response);
createReadStream(staticFile.filePath).pipe(response);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't send 200 before the file stream opens.

Line 199 pipes a ReadStream without an error handler after the 200 headers are already sent. If the file disappears or becomes unreadable after startup, the preview server can crash or return a broken 200 response.

Suggested fix
-  response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
-  if (method === "HEAD") {
-    response.end();
-    return;
-  }
-
-  createReadStream(staticFile.filePath).pipe(response);
+  if (method === "HEAD") {
+    response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
+    response.end();
+    return;
+  }
+
+  const stream = createReadStream(staticFile.filePath);
+  stream.once("error", () => {
+    if (response.headersSent) {
+      response.destroy();
+      return;
+    }
+    response.writeHead(500);
+    response.end("Internal server error");
+  });
+  stream.once("open", () => {
+    response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
+    stream.pipe(response);
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
if (method === "HEAD") {
response.end();
return;
}
createReadStream(finalPath).pipe(response);
createReadStream(staticFile.filePath).pipe(response);
if (method === "HEAD") {
response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
response.end();
return;
}
const stream = createReadStream(staticFile.filePath);
stream.once("error", () => {
if (response.headersSent) {
response.destroy();
return;
}
response.writeHead(500);
response.end("Internal server error");
});
stream.once("open", () => {
response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
stream.pipe(response);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/serve-export.mjs` around lines 193 - 199, The code sends a 200
response before ensuring the file stream is readable; change the flow so you do
not call response.writeHead(200, ...) before the ReadStream opens and add an
error handler on the stream: for method === "HEAD" you can still call
response.writeHead(200, headersFor(...)) and response.end() immediately, but for
GET create the ReadStream(staticFile.filePath), attach stream.once('open', () =>
{ response.writeHead(200, headersFor(staticFile.filePath, staticFile.size));
stream.pipe(response); }), and attach stream.on('error', err => {
response.destroy(); response.writeHead(err.code === 'ENOENT' ? 404 : 500);
response.end(); }) so missing/unreadable files don’t cause a broken 200 response
or crash.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR refactors the fragment encoding/decoding pipeline, viewer UI, and build tooling around a new arx3 codec variant and cleaner code-splitting boundaries. The ARX dictionary loading path, base-path URL helpers, static-export server, and viewer shell are all touched.

  • New fragment-arx.ts module: ARX/ARX2/ARX3 candidate building and decoding are split into a dynamically-imported chunk so the main fragment module stays light; arx-codec.ts gains arxCompressPayloads to compress once and return all four wire encodings instead of running Brotli four times.
  • New artifact-stage.tsx: Renderer loading and toolbar logic extracted from the monolithic viewer-shell.tsx, with fragment module import cached via a module-level promise.
  • serve-export.mjs overhaul: Static files are pre-indexed at startup (collectStaticFiles) with route aliases pre-computed, replacing per-request stat calls; method guard now runs before any I/O.
  • check-build-budgets.mjs: New CI gate measures gzip size of Next.js chunk groups against fixed budgets and exits non-zero on failure.

Confidence Score: 5/5

Safe to merge; no correctness bugs found in the codec, fragment, or server paths.

The ARX3 codec path, reversed-trie optimisation, brotli dictionary fallback, and artifact-stage extraction all look correct. The serve-export.mjs static-file pre-indexing is sound, and the selfhosted path-traversal guard is tightened correctly. The only open questions are minor: a startup UX gap in the preview server and the manifest key used by the budget checker, both of which would surface immediately in local testing rather than causing silent data loss.

scripts/check-build-budgets.mjs — confirm the /page manifest key against a real build output; scripts/serve-export.mjs — the top-level collectStaticFiles call produces a raw crash if out/ is missing.

Important Files Changed

Filename Overview
src/lib/payload/fragment-arx.ts New module splitting ARX candidate-building and decoding out of fragment.ts; introduces dictionary load caching with module-level promises and the intentional double-try decode pattern for backward compatibility.
src/lib/payload/arx-codec.ts Adds arx3 compress/decompress, arxCompressPayloads (compress-once, encode-four-ways), brotli-first dictionary fallback fetch, and trie-reversed parameter replacing the extra map allocation.
src/lib/payload/fragment.ts ARX imports removed; build-candidate functions now dynamic-import fragment-arx.ts; arx3 plumbed through codec selection; minor hot-path optimisations (loop-based base64, codepoint integer checks).
scripts/serve-export.mjs Static file map pre-built at startup via collectStaticFiles; method guard moved before I/O; Content-Length and brotli headers added; basePath normalization refactored. Startup will hard-crash if out/ is absent rather than returning 404.
scripts/check-build-budgets.mjs New CI budget gate measuring gzip size of route and react-loadable chunks; self-validates by throwing on unknown manifest keys.
src/components/viewer/artifact-stage.tsx New component extracting renderer loading, toolbar, copy/download, and raw-source view from viewer-shell.tsx for improved code-splitting.
src/lib/site/base-path.ts New utility normalising NEXT_PUBLIC_BASE_PATH and providing withBasePath; replaces duplicated inline env-var reads across arx-codec and viewer-shell.
selfhosted/server.ts Adds .json.br and .css.br special-case Content-Encoding/Content-Type, Cache-Control for _next/static assets, and isNextStaticAsset helper; path traversal check improved with pre-computed outputDirectoryWithSeparator.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    VS[viewer-shell.tsx] -->|dynamic import| AS[artifact-stage.tsx]
    VS -->|cached promise| FM[fragment.ts]
    FM -->|sync codecs| SC[plain / deflate / lz]
    FM -->|dynamic import| FAX[fragment-arx.ts]
    FAX -->|ensureArxLoaded| AC[arx-codec.ts]
    FAX -->|ensureArx2Loaded| AC
    AC -->|fetch with .br fallback| DICT[arx-dictionary.json]
    AC -->|fetch with .br fallback| DICT2[arx2-dictionary.json]
    AC -->|arxCompressPayloads| WP[ArxWirePayloads: base76 / base1k / baseBMP / base64url]
    FAX -->|buildArxCandidates| WP
    FAX -->|buildArx2Candidates| WP
    FAX -->|buildArx3Candidates
prefers baseBMP visible len| WP
    BP[base-path.ts
normalizeBasePath / withBasePath] -->|resolves URL| DICT
    BP -->|resolves URL| DICT2
    SX[serve-export.mjs] -->|collectStaticFiles at startup| SF[staticFiles Map]
    SF -->|pre-computed aliases| ROUTES[/ /index.html /page /page/]
Loading

Fix All in Codex

Reviews (2): Last reviewed commit: "Add ARX3 homepage sample and auto-encode..." | Re-trigger Greptile

Comment thread selfhosted/server.ts
Comment on lines +49 to 81
if (filePath.endsWith(".json.br")) {
return "application/json; charset=utf-8";
}

if (filePath.endsWith(".css.br")) {
return "text/css; charset=utf-8";
}

return contentTypes.get(path.extname(filePath)) || "application/octet-stream";
}

function isNextStaticAsset(filePath: string): boolean {
return filePath.includes(`${path.sep}_next${path.sep}static${path.sep}`);
}

function headersFor(filePath: string): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": contentTypeFor(filePath) };

if (filePath.endsWith(`${path.sep}.well-known${path.sep}api-catalog`)) {
headers.Link = API_CATALOG_LINK_HEADER;
}

if (filePath.endsWith(".json.br") || filePath.endsWith(".css.br")) {
headers["Content-Encoding"] = "br";
headers.Vary = "Accept-Encoding";
}

if (isNextStaticAsset(filePath)) {
headers["Cache-Control"] = "public, max-age=31536000, immutable";
}

return headers;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Brotli handling limited to two known types while serve-export.mjs is generic

serve-export.mjs handles every .br file by stripping the extension to determine content type and unconditionally sets Content-Encoding: br. The selfhosted server only applies the correct content type and Content-Encoding: br for .json.br and .css.br. Any future pre-compressed file (e.g., a .wasm.br or .js.br) served through the selfhosted path would be returned as application/octet-stream without Content-Encoding: br, causing the browser to receive raw brotli bytes. Aligning the two servers to use the same generic extension-stripping approach would avoid this drift.

Fix in Codex

Comment on lines +172 to +188

if (parsedDictVersion !== null) {
try {
return await decodeArxAttempt(codec, decodeArxEncodedPayload(remainder));
} catch (error) {
lastError = normalizeArxDecodeError(error);
}
}

try {
return await decodeArxAttempt(codec, decodeArxEncodedPayload(versionedPayload));
} catch (error) {
lastError = normalizeArxDecodeError(error);
}

throw lastError ?? new Error("Failed to decode arx fragment");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 First decode attempt always fails for correctly versioned ARX fragments

When parsedDictVersion !== null the function first attempts to decompress remainder (the full "<version>.<payload>" string). For any correctly-formed, version-prefixed ARX fragment this attempt will always fail at the decompressor level, the error is silently swallowed, and the second attempt with just versionedPayload succeeds. This double-try is preserved from the old fragment.ts code for backward compatibility with pre-versioning links where the payload might start with digits and a dot. The behavior is intentional, but the first failed decompress is wasted CPU on every modern fragment decode — a comment explaining why the full remainder is tried first would help readers not "fix" this.

Fix in Codex

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/payload/schema.ts (1)

146-157: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate diff.view at runtime in isPayloadEnvelope.

isPayloadEnvelope currently allows any view value on diff artifacts. Invalid values should fail schema validation before mount.

Proposed fix
     if (artifact.kind === "diff") {
       const diffArtifact = artifact as {
         patch?: unknown;
         oldContent?: unknown;
         newContent?: unknown;
+        view?: unknown;
       };

       if (!hasString(diffArtifact.patch) && (!hasString(diffArtifact.oldContent) || !hasString(diffArtifact.newContent))) {
         return false;
       }
+      if (
+        diffArtifact.view !== undefined &&
+        diffArtifact.view !== "unified" &&
+        diffArtifact.view !== "split"
+      ) {
+        return false;
+      }
       continue;
     }

As per coding guidelines, src/lib/payload/{schema,envelope}.ts: "Fail clearly on malformed or oversized payloads before renderer mount when possible in envelope.ts and schema.ts" and src/lib/payload/schema.ts: "Support view property with values unified or split for diff artifacts in schema.ts and diff-renderer.tsx".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/payload/schema.ts` around lines 146 - 157, The diff-artifact branch
in isPayloadEnvelope currently doesn't validate the diff.view value; update the
check in the artifact.kind === "diff" block (where diffArtifact is defined and
hasString is used) to also validate diffArtifact.view and return false for any
value other than "unified" or "split" (i.e., if view is present and not one of
those two strings, return false). Ensure the combined condition still enforces
the existing patch/oldContent+newContent requirement and uses the same hasString
helper for view validation.
🧹 Nitpick comments (1)
scripts/bench-codecs.mjs (1)

514-533: ⚡ Quick win

Add decode round-trip assertions in the benchmark loop.

The loop measures decode time but never verifies decoded content matches the expected payload shape for each codec. A parseable-but-wrong decode can pass the benchmark gate unnoticed.

Proposed patch
 for (const entry of corpus) {
   for (const codec of ["arx", "arx2", "arx3"]) {
     const [encoder, decoder] = codecImplementations[codec];
     const rawJson = JSON.stringify({ ...entry.envelope, codec });
+    const expectedDecoded =
+      codec === "arx"
+        ? { ...entry.envelope, codec }
+        : tupleEnvelope({ ...entry.envelope, codec });
     const encodedInput = encoder(entry.envelope);
     const encode = measure(() => brotli(encodedInput));
     const decode = measure(() => decoder(encode.result));
+    if (JSON.stringify(decode.result) !== JSON.stringify(expectedDecoded)) {
+      failures.push(`${entry.name}:${codec} failed decode round-trip validation.`);
+    }

     rows.push({
       id: `${entry.name}:${codec}`,
       codec,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bench-codecs.mjs` around lines 514 - 533, Add a decode round-trip
assertion after measuring decode in the corpus loop: after const decode =
measure(() => decoder(encode.result)); verify the decoded value matches the
original envelope (entry.envelope) using a deep-equality check (e.g.
assert.deepStrictEqual or a JSON.stringify comparison) so any
parseable-but-wrong output fails the benchmark; do the check outside the timed
measure and before pushing the row, referencing encoder/decoder, measure,
encode/decode results, and rows.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/payload/fragment-arx.ts`:
- Around line 39-40: The cached promise arxDictionaryLoadPromise can remain
rejected and block future loads; modify the usage where you set
arxDictionaryLoadPromise = loadArxDictionary()... so that you attach a .catch
handler which clears arxDictionaryLoadPromise (set it back to undefined/null)
before rethrowing the error, then await the promise; do the same fix for the
other occurrence around lines 50-53 so any transient failure resets the cache
and allows retries (refer to arxDictionaryLoadPromise and loadArxDictionary to
locate both spots).

---

Outside diff comments:
In `@src/lib/payload/schema.ts`:
- Around line 146-157: The diff-artifact branch in isPayloadEnvelope currently
doesn't validate the diff.view value; update the check in the artifact.kind ===
"diff" block (where diffArtifact is defined and hasString is used) to also
validate diffArtifact.view and return false for any value other than "unified"
or "split" (i.e., if view is present and not one of those two strings, return
false). Ensure the combined condition still enforces the existing
patch/oldContent+newContent requirement and uses the same hasString helper for
view validation.

---

Nitpick comments:
In `@scripts/bench-codecs.mjs`:
- Around line 514-533: Add a decode round-trip assertion after measuring decode
in the corpus loop: after const decode = measure(() => decoder(encode.result));
verify the decoded value matches the original envelope (entry.envelope) using a
deep-equality check (e.g. assert.deepStrictEqual or a JSON.stringify comparison)
so any parseable-but-wrong output fails the benchmark; do the check outside the
timed measure and before pushing the row, referencing encoder/decoder, measure,
encode/decode results, and rows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 55f640dc-aa1c-4cf4-b628-a0f87ef607c0

📥 Commits

Reviewing files that changed from the base of the PR and between e638be4 and 2a2cd0f.

⛔ Files ignored due to path filters (2)
  • tests/e2e/visual.spec.ts-snapshots/bundle-switcher-light-chromium.png is excluded by !**/*.png
  • tests/e2e/visual.spec.ts-snapshots/json-light-chromium.png is excluded by !**/*.png
📒 Files selected for processing (24)
  • README.md
  • docs/architecture.md
  • docs/dependency-notes.md
  • docs/payload-format.md
  • docs/testing.md
  • docs/url-fragments.md
  • scripts/bench-baseline.json
  • scripts/bench-codecs.mjs
  • skills/agent-render-linking/SKILL.md
  • skills/selfhosted-agent-render/SKILL.md
  • src/components/home/sample-link-data.ts
  • src/components/viewer-shell.tsx
  • src/lib/payload/arx-codec.ts
  • src/lib/payload/examples.ts
  • src/lib/payload/fragment-arx.ts
  • src/lib/payload/fragment.ts
  • src/lib/payload/link-creator.ts
  • src/lib/payload/schema.ts
  • tests/arx-codec.test.ts
  • tests/e2e/viewer.spec.ts
  • tests/fixtures/baanish-code-bench-report.md
  • tests/fragment.test.ts
  • tests/link-creator.test.ts
  • tests/sample-link-data.test.ts
✅ Files skipped from review due to trivial changes (3)
  • README.md
  • src/lib/payload/examples.ts
  • skills/agent-render-linking/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/components/home/sample-link-data.ts
  • tests/e2e/viewer.spec.ts
  • src/lib/payload/link-creator.ts
  • src/components/viewer-shell.tsx
  • src/lib/payload/fragment.ts
  • src/lib/payload/arx-codec.ts

Comment on lines +39 to +40
arxDictionaryLoadPromise ??= loadArxDictionary().then(() => undefined);
await arxDictionaryLoadPromise;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset cached dictionary-load promises after failures.

If one load attempt rejects, the cached rejected promise is reused forever. Subsequent calls keep failing even after transient network issues recover.

Proposed fix
 async function ensureArxDictionaryLoaded(): Promise<void> {
   if (isExternalDictionaryLoaded()) {
     return;
   }

-  arxDictionaryLoadPromise ??= loadArxDictionary().then(() => undefined);
+  arxDictionaryLoadPromise ??= loadArxDictionary()
+    .then(() => undefined)
+    .catch((error) => {
+      arxDictionaryLoadPromise = null;
+      throw error;
+    });
   await arxDictionaryLoadPromise;
 }
@@
-  arx2OverlayDictionaryLoadPromise ??= loadArx2OverlayDictionary().then(
-    () => undefined,
-  );
+  arx2OverlayDictionaryLoadPromise ??= loadArx2OverlayDictionary()
+    .then(() => undefined)
+    .catch((error) => {
+      arx2OverlayDictionaryLoadPromise = null;
+      throw error;
+    });
   await arx2OverlayDictionaryLoadPromise;
 }

Also applies to: 50-53

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/payload/fragment-arx.ts` around lines 39 - 40, The cached promise
arxDictionaryLoadPromise can remain rejected and block future loads; modify the
usage where you set arxDictionaryLoadPromise = loadArxDictionary()... so that
you attach a .catch handler which clears arxDictionaryLoadPromise (set it back
to undefined/null) before rethrowing the error, then await the promise; do the
same fix for the other occurrence around lines 50-53 so any transient failure
resets the cache and allows retries (refer to arxDictionaryLoadPromise and
loadArxDictionary to locate both spots).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants