Skip to content

Review tab: see the agent's local diff without leaving kolu#515

Merged
srid merged 25 commits intomasterfrom
sticky-dust
Apr 14, 2026
Merged

Review tab: see the agent's local diff without leaving kolu#515
srid merged 25 commits intomasterfrom
sticky-dust

Conversation

@srid
Copy link
Copy Markdown
Member

@srid srid commented Apr 14, 2026

The right panel now has a Review tab that lists files changed vs HEAD and renders the unified diff for any one of them. The old Files and Git tabs were "Coming soon" stubs — this collapses them into one tab whose whole job is answering "what did the agent just touch, and what does it look like?" without dropping to a terminal or flipping to GitHub.

Rendering is handled by @git-diff-view/solid in unified mode. The server does the git work: git.status via simple-git, git.diff via diff.createPatch + diff.parsePatch so we get Myers-algorithm hunks without the --no-index exit-1-by-design quirk that otherwise bites on untracked files. The diff library's renderWidgetLine + renderExtendLine props are the reason it was picked over @codemirror/merge or diff2html — they are exactly the hooks phase 3 (inline comments) will need.

The tab enum moves from [inspector, files, git] to [inspector, review]; schema migration 1.8.0 coerces stale persisted tab values so nobody sees a broken preferences file on upgrade. The TABS table is now Record<RightPanelTab, TabDef> — adding a new tab without wiring a component now fails to compile instead of falling through at runtime.

Scope. Closes phase 1 of #514 only. Phases 2 (PR-diff toggle), 3 (inline comments), 4 (paste-to-terminal) remain future work.

One non-obvious fix worth flagging: importing RightPanelTabSchema as a value dragged kolu-common's whole runtime graph — including AgentInfoSchemakolu-claude-code@anthropic-ai/claude-agent-sdk — into the client bundle and tripped a setMaxListeners is not exported by __vite-browser-external error during the PWA precache step. The Record already gives compile-time exhaustiveness, so the schema value isn't needed on the client; Object.keys(TABS) iterates it. Integration packages picked up sideEffects: false as belt-and-suspenders.

Try it locally

nix run github:juspay/kolu/sticky-dust

srid added 2 commits April 14, 2026 15:26
Replaces the placeholder Files and Git tabs with a single Review tab
that lists files changed vs HEAD and renders the unified diff for a
selected file via @git-diff-view/solid.

- Server: git.status + git.diff RPCs, using diff.parsePatch for hunks
- Common: RightPanelTab enum "files|git" collapsed into "review"
- State: migration 1.8.0 coerces stale persisted tab values
- Client: ReviewTab (status list + DiffView), RightPanel refactored
  to Record<RightPanelTab, TabDef> for compile-time exhaustiveness
- E2E: review-tab.feature covering empty, dirty, no-repo states

Phase 2 (PR diff toggle), phase 3 (inline comments), phase 4
(paste-to-terminal) remain future work per the issue.
- Drop value-level `RightPanelTabSchema` import from RightPanel.tsx;
  it dragged the kolu-common runtime graph (including anthropic SDK via
  AgentInfoSchema) into the client bundle and broke tree-shaking. Iterate
  via `Object.keys(TABS)` — the `Record<RightPanelTab, TabDef>` already
  gives us compile-time exhaustiveness.
- Mark integration packages + kolu-common as `sideEffects: false` so
  future value-level imports don't silently regress tree-shaking.
- Raise workbox precache limit to 4 MiB for the highlight.js + lowlight
  bundle that git-diff-view pulls in.
- Replace shell-out to `git diff --no-index` with `diff.createPatch` on
  the fetched old/new content — same Myers algorithm, no exit-1 quirks
  for untracked files, simpler code.
- Add `data-active` to tab buttons; use it + `state: "attached"` in the
  e2e step defs so tests pass regardless of in-repo vs no-repo tab state
  and without racing the diff renderer's ResizeObserver.
@srid
Copy link
Copy Markdown
Member Author

srid commented Apr 14, 2026

Hickey Analysis

Evaluated during /do's hickey pass; both findings fixed in this PR.

Finding A — hand-rolled hunk parser (Complecting: focused library)

The original plan split git diff output by ^@@ - manually. [email protected] was already transitively in the lockfile; parsePatch produces structured {oldStart, oldLines, newStart, newLines, lines, sectionHeader} hunks and handles \ No newline at end of file, multi-file diffs, and renames upstream.

Applied in packages/server/src/git-review.ts: promoted diff to a direct dep, use parsePatch and a thin reserializeHunk helper on StructuredPatchHunk. Also flipped from git diff --no-index shell-out to diff.createPatch(path, oldContent, newContent) — same Myers algorithm git uses, no exit-1-by-design trap.

Finding B — TABS exhaustiveness (Fragmentation: convention maintained by memory)

The old shape TABS: { id: RightPanelTab; label; component }[] typed the elements but not the coverage. Adding a new enum value without a TABS entry passed the type checker; the "every tab has a handler" rule lived in developer memory.

Applied in packages/client/src/right-panel/RightPanel.tsx: TABS: Record<RightPanelTab, TabDef>. Fresh-object-literal required-property check is exhaustive at the type level — missing an enum member is now a compile error. Iteration uses Object.keys(TABS) (no value-level schema import — see PR body for why that matters for tree-shaking).

Non-findings (considered, rejected)

  • git.diff returning oldContent + newContent + hunks — matches DiffView's input shape; client-side reconstruction would be wasted work.
  • ReviewTab state machine (no-repo / loading / empty / list / diff-loading / diff-shown) — inherent to the UX; createResource + <Switch>/<Match> express it declaratively.
  • Per-terminal vs per-repoPath RPC keying — multiple terminals in one repo share git state; per-repoPath is correct.
  • Migration elaboration vs zod-parse fallback — established convention, 5-line change; broader refactor deferred.

@srid
Copy link
Copy Markdown
Member Author

srid commented Apr 14, 2026

/do results

Step Status Duration Verification
sync ~11s Fetched origin, clean tree, forge=github
research ~2m38s Confirmed stubs, RPC gaps, library peer-dep match
hickey ~2m22s Found 2 fixable complexities (A: parser, B: TABS exhaustiveness)
branch ~21s On feature branch sticky-dust
implement ~5m08s RPCs, ReviewTab, RightPanel refactor, migration 1.8.0, e2e
check ~14s pnpm typecheck clean across 7 projects
docs ~12s No docs referenced the old tabs
police ~3m51s All 3 passes clean after 3 fixes
fmt ~13s just fmt clean
commit ~31s 2608c3a pushed
test ~13m50s 16 scenarios pass (4 review-tab + 12 right-panel)
ci ~3m29s All 12 contexts green on d9c506a
update-pr ~1m18s Draft PR with narrative body + hickey comment
Total ~34m22s

Optimization suggestions

  • test was the slowest step (~40% of wall-clock) because I burned a chunk of it debugging a tree-shake regression the PWA precache caught only after a full vite build. Now that the sideEffects/type-only-import fix is in, future Phase 2 runs won't repeat that investigation.
  • implement (~5m) included one full pnpm install and a pnpmDeps hash round-trip. For Phase 2 re-use, --from implement starts here.
  • police (~4m) needed three fixes (inline catch comments, selectedPath reset, refresh-refetches-diff) — none from the rules pass. Adding a SolidJS component checklist for "reset per-repo state on repo change" could catch the first one earlier.
  • research + hickey (~5m) — most of that was verifying library APIs by reading @git-diff-view/solid source. Faster on future phases since the library internals are now understood.

Workflow completed at 2026-04-14T19:46Z.

srid added 8 commits April 14, 2026 15:55
The library's DiffParser expects each `hunks[]` entry to be a complete
unified diff — it scans forward for `---` then `+++` before it will
recognize any `@@` blocks (see `parseDiffHeader` in
`@git-diff-view/core`). My previous reserializer stripped those headers
and passed just the `@@` body, so the parser returned null and every
real diff dropped through silently with a "No hunks found" warn.

Pass `createPatch`'s output verbatim as a single-entry array. Drop
`parsePatch` + reserialize entirely — the library already knows how to
split hunks once it has the header.

Tightened the e2e assertion to wait for `.diff-line[data-state="diff"]`
(the library's per-row marker in DiffUnifiedContentLine) instead of
just the outer wrapper. The wrapper mounts even when zero hunks parse,
which is how the original bug slipped past green tests.
Preferences are recoverable — every field can be re-toggled by the user
— so a schema mismatch should reset to defaults, not require a migration
entry per change. `getServerState` now runs `PreferencesSchema.safeParse`
on the persisted blob and falls back to `DEFAULT_PREFERENCES` on any
mismatch. Migrations remain for `recentRepos`, `recentAgents`, and
`session` — accumulated user data the user can't reconstruct.

Drops the v1.8.0 migration added in 2608c3a (RightPanelTab enum
collapse) and reverts SCHEMA_VERSION to 1.7.0; the safeParse path
handles the stale "files"/"git" tab values for free.

Updates the state.instructions.md rule to document the split:
preferences = safeParse, user data = migration. Regenerated
.claude/rules/state.md via just ai::apm.
Comment thread packages/common/src/index.ts Outdated
srid added 3 commits April 14, 2026 16:36
The library wraps its table in `class="table-fixed"` (which compiles to
`table-layout: fixed`). Under fixed layout, column widths are locked
by inline `width: calc(...)` on the first row's cells, and `width: auto
!important` on those cells is a no-op. Force `table-layout: auto !important`
so columns size to their content; the cell collapses from 185px to ~21px.

Verified via headless Playwright against the dev server.
…igration"

The user prefers explicit migrations across all PersistedState fields
over the safeParse-on-read shortcut for preferences. Restore the v1.8.0
migration that coerces stale RightPanelTab values, bump SCHEMA_VERSION
back to 1.8.0, and revert the state.instructions.md rule to its original
"must add a migration" wording.

This reverts the structural change in e11e658 while keeping the original
v1.8.0 migration from 2608c3a.
Per PR review on packages/common/src/index.ts:51 — single-letter status
codes belong in `z.enum(["M","A","D","R","C","U","T","?"])`, narrowed to
the set `git.status` actually surfaces (excludes " " unmodified and "!"
ignored). `getStatus` now coerces the porcelain letter via `safeParse`,
falling back to "?" defensively if simple-git ever surfaces an unknown
code.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a Review tab to the right panel that shows local working-tree changes vs HEAD and renders a unified diff for a selected file, backed by new server RPC endpoints and a persisted-tab migration.

Changes:

  • Replace the right-panel tab set with [inspector, review], and add a Review UI that lists changed files and renders a unified diff via @git-diff-view/solid.
  • Add server-side git review endpoints (git.status, git.diff) and shared contract/schema types for status + diff payloads.
  • Add e2e coverage for Review tab presence, empty/no-repo states, and diff rendering.

Reviewed changes

Copilot reviewed 20 out of 21 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
pnpm-lock.yaml Locks new deps for diff rendering and server diff generation.
packages/tests/step_definitions/review_tab_steps.ts Adds cucumber steps for Review tab interactions/assertions.
packages/tests/features/review-tab.feature Adds e2e scenarios covering Review tab states and diff rendering.
packages/server/src/state.ts Bumps schema version and migrates persisted right-panel tab values.
packages/server/src/router.ts Wires new git.status / git.diff handlers into the RPC router.
packages/server/src/git-review.ts Implements status listing and unified diff creation (HEAD vs working tree).
packages/server/package.json Adds diff dependency (and types entry).
packages/integrations/opencode/package.json Marks package as sideEffects: false for bundling/tree-shaking.
packages/integrations/common/package.json Marks package as sideEffects: false for bundling/tree-shaking.
packages/integrations/claude-code/package.json Marks package as sideEffects: false for bundling/tree-shaking.
packages/common/src/index.ts Adds git review schemas/types; updates RightPanelTab enum to review.
packages/common/src/contract.ts Exposes new git.status / git.diff endpoints in the shared contract.
packages/common/package.json Marks package as sideEffects: false for bundling/tree-shaking.
packages/client/vite.config.ts Increases Workbox max precache size for diff/highlight bundle.
packages/client/src/right-panel/review-tab.css CSS overrides to fit the diff view into the narrow side panel.
packages/client/src/right-panel/RightPanel.tsx Replaces tab wiring with a typed Record and adds Review tab entry.
packages/client/src/right-panel/ReviewTab.tsx New Review tab UI: status list + unified diff viewer.
packages/client/src/right-panel/GitTab.tsx Removes obsolete stub tab.
packages/client/src/right-panel/FilesTab.tsx Removes obsolete stub tab.
packages/client/package.json Adds @git-diff-view/solid dependency.
default.nix Updates dependency hash for Nix build reproducibility.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/server/package.json Outdated
Comment thread packages/server/src/git-review.ts
Comment thread packages/server/src/git-review.ts Outdated
Comment thread packages/server/src/git-review.ts
Comment thread packages/server/src/git-review.ts
Comment thread packages/common/src/index.ts Outdated
Comment thread packages/common/src/contract.ts Outdated
Comment thread packages/server/src/git-review.ts
Comment thread packages/server/src/git-review.ts
Comment thread packages/server/src/git-review.ts
srid added 2 commits April 14, 2026 16:53
…th guard

- Drop `diff` + `@types/diff` deps. `getDiff` now pipes `git diff HEAD --`
  output through verbatim (and falls back to `git diff --no-index` for
  untracked files, capturing stdout through the exit-1-by-design via
  `execFile`). Same unified diff the working tree is already staring at
  in the terminal, no intermediate `createPatch` call.
- Add `resolveInRepo()` guard: reject `filePath` values that escape the
  repo root via `../` before any `fs.readFile`/`git.show`.
- Tighten catches:
  - `readHeadContent` probes with `git cat-file -e HEAD:<file>` and only
    swallows that specific "not in HEAD" failure; every other git error
    propagates.
  - `readWorkingContent` only converts ENOENT into the empty-target
    sentinel; permission/EISDIR/etc. propagate.
- Restore `getStatus`'s doc comment (had been orphaned above
  `toChangeStatus` when the helper was inserted) and tighten docs on
  `GitDiffOutputSchema.hunks` + `git.diff` contract to match the
  actual passthrough shape (full `--- / +++ / @@` unified diff, not
  per-hunk fragments).
srid added 5 commits April 14, 2026 18:00
- Path-traversal guard: `resolveInRepo` now returns both abs + rel path;
  every downstream git invocation uses the normalized rel, so no code
  path reads the raw untrusted `filePath` string (closes the apparent
  bypass — the guard was complete, but reviewers had to trust the
  ordering; the normalized return makes it mechanical).
- Log security events: `resolveInRepo` calls `log.error` before
  throwing, so a path-escape attempt surfaces in the operator log.
- Tighter `readHeadContent` catch: dropped the `cat-file -e` probe;
  call `git show` directly and only swallow the specific
  "does not exist in" / "exists on disk, but not in" messages that
  mean "absent from HEAD". Permission denied, corrupted repo, missing
  HEAD now propagate to the client instead of becoming a silent empty.
- Empty-diff observability: `log.warn` when both `git diff HEAD --` and
  `--no-index` produce zero output for a file the status listed. Rare
  but possible (mode-only change already reset, race with external
  `git reset`), worth a log line for operators.
Move the `resolveInRepo` helper out of `git-review.ts` — it's not
git-specific, it's the generic "resolve untrusted child path under
trusted root, or reject" guard that any RPC handler touching the
filesystem wants. Now lives in `safe-path.ts` as `resolveUnder(root,
child)`.

While moving:
- Switch the check from `startsWith(rootAbs + path.sep)` to the
  `path.relative` idiom (`..` prefix ⇒ outside). Same guarantee, no
  trailing-slash / path-sep gotcha, and `rel` is what we return anyway
  — one computation instead of two.
- `getDiff` now passes the pre-validated absolute path (`abs`) to the
  `git diff --no-index` fallback instead of the relative one.
  `--no-index`'s behavior w.r.t. cwd is less stable than
  `git diff HEAD --`, and the whole point of this guard is that no
  downstream subprocess sees the raw untrusted string.

Verified via live chrome-devtools-mcp probe that rendered diff metrics
are identical pre/post refactor.
Clicking the active file row toggles selectedPath to null, which
collapses back to the "Select a file to view its diff" fallback.
Previously a re-click was a silent no-op.

e2e: extended the "lists changed files and opens a diff on click"
scenario with a second click + a new "should not render a diff view"
assertion (waits for the diff row to detach).
@srid srid marked this pull request as ready for review April 14, 2026 22:42
@srid srid enabled auto-merge (squash) April 14, 2026 22:42
@srid srid merged commit aa69778 into master Apr 14, 2026
13 checks passed
@srid srid deleted the sticky-dust branch April 14, 2026 22:44
srid added a commit that referenced this pull request Apr 15, 2026
Renames KOLU_STATE_SUFFIX to KOLU_ID and removes the silent fallback to
production state (~/.config/kolu) when no suffix was set. The fallback
was masking a real bug: running the server from source (e.g. `tsx
src/index.ts` in a worktree) would read and write the production state
file, clobbering real user state with test/dev mutations and — post-#515
— hitting opaque "Event iterator validation failed" on state.get when
the stored rightPanel.tab was a pre-migration "files"/"git".

Every entrypoint must now name its bucket: "kolu" for the nix-built
binary (only caller allowed to touch production state), "kolu-dev" for
`just dev`, "kolu-test/<uuid>-<worker>" for e2e tests. Unset throws at
boot with a clear message.

KOLU_ID doubles as the config dir name directly (conf's projectName),
so test runs can nest under ~/.config/kolu-test/<id>/ without relying
on conf's projectSuffix concatenation.
srid added a commit that referenced this pull request Apr 15, 2026
The `?? ""` fallback in state.ts meant running the server from source
without setting KOLU_STATE_SUFFIX (e.g. `tsx src/index.ts` from a
worktree) silently wrote to the production state dir (~/.config/kolu).
Once PR #515 narrowed `RightPanelTabSchema` to [inspector, review], any
dev/ad-hoc invocation that had persisted a pre-migration `tab: "files"`
would fail `state.get` output validation, bricking the client with an
opaque "Event iterator validation failed" — recent repos (and
everything else streamed off `sub()`) stayed empty.

Now: missing or empty KOLU_STATE_SUFFIX throws at module load. The
nix-built binary sets KOLU_STATE_SUFFIX="prod", the only sentinel
that maps to projectSuffix="" (the real ~/.config/kolu). `just dev`,
`test:unit`, and e2e hooks already set their own non-"prod" suffixes.
srid added a commit that referenced this pull request Apr 15, 2026
Running the server from source without scoping its state — `tsx
packages/server/src/index.ts` from a worktree, an installed binary on a
branch without the right wrapper, anything that doesn't set
`KOLU_STATE_SUFFIX` — silently wrote to the same
`~/.config/kolu/config.json` the production `kolu` binary uses. Nobody
noticed until PR #515 narrowed `RightPanelTabSchema` to `[inspector,
review]`: a pre-existing `tab: "files"` in that shared state file made
`state.get` fail zod output validation and surface at the client as an
opaque `Event iterator validation failed`, leaving the subscription
hanging — so `recentRepos()` returns `[]` and the "New terminal" palette
shows no repos.

**The fix is upstream of that symptom.** Sharing the production state
file with dev/ad-hoc runs was the actual bug; the 1.8.0 migration only
helped if it ran against your state in the first place. This PR replaces
the silent fallback with a hard crash.

- `state.ts` now throws at module load if `KOLU_STATE_SUFFIX` is unset
or empty. No `?? ""`, no "helpful" default.
- Production uses the sentinel `KOLU_STATE_SUFFIX="prod"`, which maps to
conf's `projectSuffix=""` and therefore the real `~/.config/kolu`.
`default.nix`'s `makeWrapper` invocation bakes this in, so only the
nix-built binary reaches production state.
- `just dev` (`kolu-dev`), `pnpm test:unit` (`kolu-test`), and the e2e
hooks (`kolu-test-<uuid>-<worker>`) already set their own non-`prod`
suffixes, so nothing else changes.

Run the server from source without an explicit suffix now and you get a
stack trace, not a silently corrupted production state file.

### Try it locally

```sh
nix run github:juspay/kolu/fix/require-kolu-id
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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