Review tab: see the agent's local diff without leaving kolu#515
Review tab: see the agent's local diff without leaving kolu#515
Conversation
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.
Hickey AnalysisEvaluated during Finding A — hand-rolled hunk parser (Complecting: focused library)The original plan split Applied in Finding B — TABS exhaustiveness (Fragmentation: convention maintained by memory)The old shape Applied in Non-findings (considered, rejected)
|
|
| 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
testwas 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 implementstarts 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/solidsource. Faster on future phases since the library internals are now understood.
Workflow completed at 2026-04-14T19:46Z.
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.
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.
There was a problem hiding this comment.
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.
…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).
- 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).
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.
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.
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)
The right panel now has a Review tab that lists files changed vs
HEADand 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/solidin unified mode. The server does the git work:git.statusviasimple-git,git.diffviadiff.createPatch+diff.parsePatchso we get Myers-algorithm hunks without the--no-indexexit-1-by-design quirk that otherwise bites on untracked files. The diff library'srenderWidgetLine+renderExtendLineprops are the reason it was picked over@codemirror/mergeordiff2html— they are exactly the hooks phase 3 (inline comments) will need.The tab enum moves from
[inspector, files, git]to[inspector, review]; schema migration1.8.0coerces stale persisted tab values so nobody sees a broken preferences file on upgrade. TheTABStable is nowRecord<RightPanelTab, TabDef>— adding a new tab without wiring a component now fails to compile instead of falling through at runtime.One non-obvious fix worth flagging: importing
RightPanelTabSchemaas a value draggedkolu-common's whole runtime graph — includingAgentInfoSchema→kolu-claude-code→@anthropic-ai/claude-agent-sdk— into the client bundle and tripped asetMaxListeners is not exported by __vite-browser-externalerror during the PWA precache step. TheRecordalready gives compile-time exhaustiveness, so the schema value isn't needed on the client;Object.keys(TABS)iterates it. Integration packages picked upsideEffects: falseas belt-and-suspenders.Try it locally