diff --git a/README.md b/README.md index 45b2cf3c6..ed279a0ed 100644 --- a/README.md +++ b/README.md @@ -1,276 +1,59 @@ -

- Kanna -

+# better-diff +normal diff too much. what if diffs were tldrs i can zoom in and out of -

Kanna

+## concept +code review has been getting harder with the volume of code to go through due to ai assisted code generation (its a good thing only) +looking at diffs kind of became pointless. raw diffs as it is i mean. -

- A beautiful web UI for the Claude Code & Codex CLIs -

+you might have come across issues like these +- large diff block and its just formatter things, but interleaved with important small changes that i might miss +- i have to jump around multiple parts of the diff since diffs are alpahbetically ordered by file name, not diff blocks by the execution flow they appear +- fatigue creeps in by just seeing a 2000 line change to comb through -

- npm version -

+i have to aggressviely prioritize what i should go over and what i can just skim through these days +i have to zoom in on things that need scrutiny but brush over things that are arbitary -
+so something i did at work is: +1. i treat each diff block as my unit +cleanshot 2026-04-16 at 15 11 37@2x -

- - - - Kanna screenshot - -

+2. i take diffs and get the llm to rearrange it in the execution / data flow order. (it splits up blocks within the files as well) +cleanshot 2026-04-16 at 15 12 12@2x -
+3. then i get a one liner description for each of the diff like "formatter changed" "anthropic key changed to openrouter" +cleanshot 2026-04-16 at 15 13 03@2x -## Quickstart +4. and then i get a pr level or natural language description of the entire diff +cleanshot 2026-04-16 at 15 13 30@2x -```bash -bun install -g kanna-code -``` +5. and read it all the way from the bottom to top, so that i go from the least information to detail as i need -If Bun isn't installed, install it first: +i jump in and out out of my summaries, natural lang diffs, rearranged diffs, and the raw diffs. -```bash -curl -fsSL https://bun.sh/install | bash -``` +this is the core idea, and i wanted this as a core primtive in my code editor / agent workspace. the old diff view just feels slow to work with. -Then run from any project directory: +in an ideal world i can blindly rely on the ai you wouldnt need this +but in places where its no there yet, i felt like there needs to be a middle ground, and i think this is it. -```bash -kanna -``` +this wasnt possible before this general intelligence being available. +reordering diff blocks require some intelligence. +converting them into natural language defo. +making a flexible tldr of that, yeah. -That's it. Kanna opens in your browser at [`localhost:3210`](http://localhost:3210). +cant this be just a skill, i think most things could be represented by a instruction and a text output. +there is a seamless in and out experience i wanted and how it integrates with other system +text could represent this but i think its too inefficient of a representation and is too much detail for me to handle at once +i like boxes hiding away detail from me -## Features +## dev flow +- we used codex for the most part obvs. +- we primarily worked on top of this ui : https://github.com/jakemor/kanna which works with codex app server part of codex cli installed locally. +- here is a dummy we built to test if the diff viewer works : https://github.com/AlferdMurray/yt-spotify-migrations +- what we implemented is extending the diff viewer to have the the phases, the llm part of it is all via the same app server. -- **Multi-provider support** — switch between Claude and Codex (OpenAI) from the chat input, with per-provider model selection, reasoning effort controls, and Codex fast mode -- **Project-first sidebar** — chats grouped under projects, with live status indicators (idle, running, waiting, failed) -- **Drag-and-drop project ordering** — reorder project groups in the sidebar with persistent ordering -- **Local project discovery** — auto-discovers projects from both Claude and Codex local history -- **Rich transcript rendering** — hydrated tool calls, collapsible tool groups, plan mode dialogs, and interactive prompts with full result display -- **Quick responses** — lightweight structured queries (e.g. title generation) via Haiku with automatic Codex fallback -- **Plan mode** — review and approve agent plans before execution -- **Persistent local history** — refresh-safe routes backed by JSONL event logs and compacted snapshots -- **Auto-generated titles** — chat titles generated in the background via Claude Haiku -- **Session resumption** — resume agent sessions with full context preservation -- **WebSocket-driven** — real-time subscription model with reactive state broadcasting - -## Architecture - -``` -Browser (React + Zustand) - ↕ WebSocket -Bun Server (HTTP + WS) - ├── WSRouter ─── subscription & command routing - ├── AgentCoordinator ─── multi-provider turn management - ├── ProviderCatalog ─── provider/model/effort normalization - ├── QuickResponseAdapter ─── structured queries with provider fallback - ├── EventStore ─── JSONL persistence + snapshot compaction - └── ReadModels ─── derived views (sidebar, chat, projects) - ↕ stdio -Claude Agent SDK / Codex App Server (local processes) - ↕ -Local File System (~/.kanna/data/, project dirs) -``` - -**Key patterns:** Event sourcing for all state mutations. CQRS with separate write (event log) and read (derived snapshots) paths. Reactive broadcasting — subscribers get pushed fresh snapshots on every state change. Multi-provider agent coordination with tool gating for user-approval flows. Provider-agnostic transcript hydration for unified rendering. - -## Requirements - -- [Bun](https://bun.sh) v1.3.5+ -- A working [Claude Code](https://docs.anthropic.com/en/docs/claude-code) environment -- _(Optional)_ [Codex CLI](https://github.com/openai/codex) for Codex provider support - -Embedded terminal support uses Bun's native PTY APIs and currently works on macOS/Linux. - -## Install - -Install Kanna globally: - -```bash -bun install -g kanna-code -``` - -If Bun isn't installed, install it first: - -```bash -curl -fsSL https://bun.sh/install | bash -``` - -Or clone and build from source: - -```bash -git clone https://github.com/jakemor/kanna.git -cd kanna -bun install -bun run build -``` - -## Usage - -```bash -kanna # start with defaults (localhost only) -kanna --port 4000 # custom port -kanna --no-open # don't open browser -kanna --password # require a password before loading the app -kanna --share # create a public quick tunnel + terminal QR -kanna --cloudflared # run a named Cloudflare tunnel from a token -``` - -Default URL: `http://localhost:3210` - -### Network access (Tailscale / LAN) - -By default Kanna binds to `127.0.0.1` (localhost only). Use `--host` to bind a specific interface, or `--remote` as a shorthand for `0.0.0.0`: - -```bash -kanna --remote # bind all interfaces — browser opens localhost:3210 -kanna --host dev-box # bind to a specific hostname — browser opens http://dev-box:3210 -kanna --host 192.168.1.x # bind to a specific LAN IP -kanna --host 100.64.x.x # bind to a specific Tailscale IP -``` - -When `--host ` is given, the browser opens `http://:3210` automatically. Other machines on your network can connect to the same URL: - -### Password protection - -Use `--password` to require a launch password before the app or websocket can connect: - -```bash -kanna --password my-secret -bun run dev --password my-secret -``` - -Kanna verifies the password once, then sets a browser-session cookie. The password itself is not stored in the browser. -When password protection is enabled, the backend requires authentication for API routes, `/health`, and `/ws`, and the production server redirects unauthenticated browser requests to a login screen. - -### Public share link - -Use `--share` to create a temporary public `trycloudflare.com` URL and print a terminal QR code: - -```bash -kanna --share -kanna --share --port 4000 -kanna --cloudflared -``` - -`--share` is incompatible with `--host` and `--remote`. It does not open a browser automatically. - -Without a token, it prints: - -```text -QR Code: -... - -Public URL: -https://.trycloudflare.com - -Local URL: -http://localhost:3210 -``` - -With `--cloudflared `, Kanna runs `cloudflared tunnel run --token --url `. -If Kanna can detect the public hostname from cloudflared output, it prints the same QR/public/local block. -If not, it keeps the tunnel running, warns that no public hostname was detected, and prints the local URL so you can use the hostname already configured for that tunnel in Cloudflare. - -## Development - -```bash -bun run dev -``` - -The same `--remote` and `--host` flags can be used with `bun run dev` for remote development. -`--share` is also supported in dev mode and exposes the Vite client URL publicly: - -```bash -bun run dev --share -bun run dev --cloudflared -bun run dev --port 3333 --share -``` - -In dev, `--port` sets the Vite client port and the backend runs on `port + 1`, so `bun run dev --port 3333 --share` publishes `http://localhost:3333`. -`--share` remains incompatible with `--host` and `--remote`. -Use `bun run dev --port 4000` to run the Vite client on `4000` and the backend on `4001`. - -Or run client and server separately: - -```bash -bun run dev:client # http://localhost:5174 -bun run dev:server # http://localhost:5175 -``` - -## Scripts - -| Command | Description | -| -------------------- | ---------------------------- | -| `bun run build` | Build for production | -| `bun run check` | Typecheck + build | -| `bun run dev` | Run client + server together | -| `bun run dev:client` | Vite dev server only | -| `bun run dev:server` | Bun backend only | -| `bun run start` | Start production server | - -## Project Structure - -``` -src/ -├── client/ React UI layer -│ ├── app/ App router, pages, central state hook, socket client -│ ├── components/ Messages, chat chrome, dialogs, buttons, inputs -│ ├── hooks/ Theme, standalone mode detection -│ ├── stores/ Zustand stores (chat input, preferences, project order) -│ └── lib/ Formatters, path utils, transcript parsing -├── server/ Bun backend -│ ├── cli.ts CLI entry point & browser launcher -│ ├── server.ts HTTP/WS server setup & static serving -│ ├── agent.ts AgentCoordinator (multi-provider turn management) -│ ├── codex-app-server.ts Codex App Server JSON-RPC client -│ ├── provider-catalog.ts Provider/model/effort normalization -│ ├── quick-response.ts Structured queries with provider fallback -│ ├── ws-router.ts WebSocket message routing & subscriptions -│ ├── event-store.ts JSONL persistence, replay & compaction -│ ├── discovery.ts Auto-discover projects from Claude and Codex local state -│ ├── read-models.ts Derive view models from event state -│ └── events.ts Event type definitions -└── shared/ Shared between client & server - ├── types.ts Core data types, provider catalog, transcript entries - ├── tools.ts Tool call normalization and hydration - ├── protocol.ts WebSocket message protocol - ├── ports.ts Port configuration - └── branding.ts App name, data directory paths -``` - -## Data Storage - -All state is stored locally at `~/.kanna/data/`: - -| File | Purpose | -| ---------------- | ----------------------------------------- | -| `projects.jsonl` | Project open/remove events | -| `chats.jsonl` | Chat create/rename/delete events | -| `messages.jsonl` | Transcript message entries | -| `turns.jsonl` | Agent turn start/finish/cancel events | -| `snapshot.json` | Compacted state snapshot for fast startup | - -Event logs are append-only JSONL. On startup, Kanna replays the log tail after the last snapshot, then compacts if the logs exceed 2 MB. - -## Star History - - - - - - Star History Chart - - - -## Contributing - -Contributions are welcome! Feel free to open PRs - -## License - -[MIT](LICENSE) +### how to run this +1. have bun +2. do bun install +3. bun install +4. bun run dev +5. load up your project run something and check the diff out \ No newline at end of file diff --git a/README_OG.md b/README_OG.md new file mode 100644 index 000000000..45b2cf3c6 --- /dev/null +++ b/README_OG.md @@ -0,0 +1,276 @@ +

+ Kanna +

+ +

Kanna

+ +

+ A beautiful web UI for the Claude Code & Codex CLIs +

+ +

+ npm version +

+ +
+ +

+ + + + Kanna screenshot + +

+ +
+ +## Quickstart + +```bash +bun install -g kanna-code +``` + +If Bun isn't installed, install it first: + +```bash +curl -fsSL https://bun.sh/install | bash +``` + +Then run from any project directory: + +```bash +kanna +``` + +That's it. Kanna opens in your browser at [`localhost:3210`](http://localhost:3210). + +## Features + +- **Multi-provider support** — switch between Claude and Codex (OpenAI) from the chat input, with per-provider model selection, reasoning effort controls, and Codex fast mode +- **Project-first sidebar** — chats grouped under projects, with live status indicators (idle, running, waiting, failed) +- **Drag-and-drop project ordering** — reorder project groups in the sidebar with persistent ordering +- **Local project discovery** — auto-discovers projects from both Claude and Codex local history +- **Rich transcript rendering** — hydrated tool calls, collapsible tool groups, plan mode dialogs, and interactive prompts with full result display +- **Quick responses** — lightweight structured queries (e.g. title generation) via Haiku with automatic Codex fallback +- **Plan mode** — review and approve agent plans before execution +- **Persistent local history** — refresh-safe routes backed by JSONL event logs and compacted snapshots +- **Auto-generated titles** — chat titles generated in the background via Claude Haiku +- **Session resumption** — resume agent sessions with full context preservation +- **WebSocket-driven** — real-time subscription model with reactive state broadcasting + +## Architecture + +``` +Browser (React + Zustand) + ↕ WebSocket +Bun Server (HTTP + WS) + ├── WSRouter ─── subscription & command routing + ├── AgentCoordinator ─── multi-provider turn management + ├── ProviderCatalog ─── provider/model/effort normalization + ├── QuickResponseAdapter ─── structured queries with provider fallback + ├── EventStore ─── JSONL persistence + snapshot compaction + └── ReadModels ─── derived views (sidebar, chat, projects) + ↕ stdio +Claude Agent SDK / Codex App Server (local processes) + ↕ +Local File System (~/.kanna/data/, project dirs) +``` + +**Key patterns:** Event sourcing for all state mutations. CQRS with separate write (event log) and read (derived snapshots) paths. Reactive broadcasting — subscribers get pushed fresh snapshots on every state change. Multi-provider agent coordination with tool gating for user-approval flows. Provider-agnostic transcript hydration for unified rendering. + +## Requirements + +- [Bun](https://bun.sh) v1.3.5+ +- A working [Claude Code](https://docs.anthropic.com/en/docs/claude-code) environment +- _(Optional)_ [Codex CLI](https://github.com/openai/codex) for Codex provider support + +Embedded terminal support uses Bun's native PTY APIs and currently works on macOS/Linux. + +## Install + +Install Kanna globally: + +```bash +bun install -g kanna-code +``` + +If Bun isn't installed, install it first: + +```bash +curl -fsSL https://bun.sh/install | bash +``` + +Or clone and build from source: + +```bash +git clone https://github.com/jakemor/kanna.git +cd kanna +bun install +bun run build +``` + +## Usage + +```bash +kanna # start with defaults (localhost only) +kanna --port 4000 # custom port +kanna --no-open # don't open browser +kanna --password # require a password before loading the app +kanna --share # create a public quick tunnel + terminal QR +kanna --cloudflared # run a named Cloudflare tunnel from a token +``` + +Default URL: `http://localhost:3210` + +### Network access (Tailscale / LAN) + +By default Kanna binds to `127.0.0.1` (localhost only). Use `--host` to bind a specific interface, or `--remote` as a shorthand for `0.0.0.0`: + +```bash +kanna --remote # bind all interfaces — browser opens localhost:3210 +kanna --host dev-box # bind to a specific hostname — browser opens http://dev-box:3210 +kanna --host 192.168.1.x # bind to a specific LAN IP +kanna --host 100.64.x.x # bind to a specific Tailscale IP +``` + +When `--host ` is given, the browser opens `http://:3210` automatically. Other machines on your network can connect to the same URL: + +### Password protection + +Use `--password` to require a launch password before the app or websocket can connect: + +```bash +kanna --password my-secret +bun run dev --password my-secret +``` + +Kanna verifies the password once, then sets a browser-session cookie. The password itself is not stored in the browser. +When password protection is enabled, the backend requires authentication for API routes, `/health`, and `/ws`, and the production server redirects unauthenticated browser requests to a login screen. + +### Public share link + +Use `--share` to create a temporary public `trycloudflare.com` URL and print a terminal QR code: + +```bash +kanna --share +kanna --share --port 4000 +kanna --cloudflared +``` + +`--share` is incompatible with `--host` and `--remote`. It does not open a browser automatically. + +Without a token, it prints: + +```text +QR Code: +... + +Public URL: +https://.trycloudflare.com + +Local URL: +http://localhost:3210 +``` + +With `--cloudflared `, Kanna runs `cloudflared tunnel run --token --url `. +If Kanna can detect the public hostname from cloudflared output, it prints the same QR/public/local block. +If not, it keeps the tunnel running, warns that no public hostname was detected, and prints the local URL so you can use the hostname already configured for that tunnel in Cloudflare. + +## Development + +```bash +bun run dev +``` + +The same `--remote` and `--host` flags can be used with `bun run dev` for remote development. +`--share` is also supported in dev mode and exposes the Vite client URL publicly: + +```bash +bun run dev --share +bun run dev --cloudflared +bun run dev --port 3333 --share +``` + +In dev, `--port` sets the Vite client port and the backend runs on `port + 1`, so `bun run dev --port 3333 --share` publishes `http://localhost:3333`. +`--share` remains incompatible with `--host` and `--remote`. +Use `bun run dev --port 4000` to run the Vite client on `4000` and the backend on `4001`. + +Or run client and server separately: + +```bash +bun run dev:client # http://localhost:5174 +bun run dev:server # http://localhost:5175 +``` + +## Scripts + +| Command | Description | +| -------------------- | ---------------------------- | +| `bun run build` | Build for production | +| `bun run check` | Typecheck + build | +| `bun run dev` | Run client + server together | +| `bun run dev:client` | Vite dev server only | +| `bun run dev:server` | Bun backend only | +| `bun run start` | Start production server | + +## Project Structure + +``` +src/ +├── client/ React UI layer +│ ├── app/ App router, pages, central state hook, socket client +│ ├── components/ Messages, chat chrome, dialogs, buttons, inputs +│ ├── hooks/ Theme, standalone mode detection +│ ├── stores/ Zustand stores (chat input, preferences, project order) +│ └── lib/ Formatters, path utils, transcript parsing +├── server/ Bun backend +│ ├── cli.ts CLI entry point & browser launcher +│ ├── server.ts HTTP/WS server setup & static serving +│ ├── agent.ts AgentCoordinator (multi-provider turn management) +│ ├── codex-app-server.ts Codex App Server JSON-RPC client +│ ├── provider-catalog.ts Provider/model/effort normalization +│ ├── quick-response.ts Structured queries with provider fallback +│ ├── ws-router.ts WebSocket message routing & subscriptions +│ ├── event-store.ts JSONL persistence, replay & compaction +│ ├── discovery.ts Auto-discover projects from Claude and Codex local state +│ ├── read-models.ts Derive view models from event state +│ └── events.ts Event type definitions +└── shared/ Shared between client & server + ├── types.ts Core data types, provider catalog, transcript entries + ├── tools.ts Tool call normalization and hydration + ├── protocol.ts WebSocket message protocol + ├── ports.ts Port configuration + └── branding.ts App name, data directory paths +``` + +## Data Storage + +All state is stored locally at `~/.kanna/data/`: + +| File | Purpose | +| ---------------- | ----------------------------------------- | +| `projects.jsonl` | Project open/remove events | +| `chats.jsonl` | Chat create/rename/delete events | +| `messages.jsonl` | Transcript message entries | +| `turns.jsonl` | Agent turn start/finish/cancel events | +| `snapshot.json` | Compacted state snapshot for fast startup | + +Event logs are append-only JSONL. On startup, Kanna replays the log tail after the last snapshot, then compacts if the logs exceed 2 MB. + +## Star History + + + + + + Star History Chart + + + +## Contributing + +Contributions are welcome! Feel free to open PRs + +## License + +[MIT](LICENSE) diff --git a/feature-plan.md b/feature-plan.md new file mode 100644 index 000000000..96115a1e0 --- /dev/null +++ b/feature-plan.md @@ -0,0 +1,528 @@ +# Feature Plan: Integrate the Git Diff Analyzer + +## Goal + +Bring the `ui-test` Git Diff Analyzer into the main Kanna app so the right sidebar can show real AI-ordered, hunk-level diff notes instead of the current prototype ordering. + +The integrated feature should: + +- Analyze the active project's current git changes from the existing right sidebar workflow. +- Split unified diffs into smaller change blocks with compact model context and expandable viewer context. +- Ask Codex to order those blocks by data flow and write one-sentence notes plus an overall summary. +- Stream progress/status into the existing React UI. +- Preserve the friend UI direction already present in `src/client/components/chat-ui/RightSidebar.tsx`: `Raw`, `Reordered`, `Natural Language`, and `Summary` panes. + +## What Exists Now + +### `ui-test` + +The standalone proof of work contains the real feature behavior: + +- `ui-test/shared/diffHunks.js` splits unified diffs into numbered blocks like `H001`, with: + - a compact `diff` sent to the model, + - `contextBefore` and `contextAfter` kept for UI expansion, + - stable titles like `H001 src/file.ts block 1`. +- `ui-test/shared/diffStats.js` computes files, hunks, additions, deletions. +- `ui-test/shared/parseAgentResponse.js` parses streamed Codex output using `--- CHANGE NOTE ---` and `--- SUMMARY ---` markers. +- `ui-test/shared/command.js` validates git diff commands and can force `--unified=N`. +- `ui-test/server.js` starts `codex app-server`, runs git diff, prompts Codex, listens to plan/message notifications, and pushes state to the browser through SSE. +- `ui-test/public/app.js` renders live status, plan updates, the total summary, ordered hunk cards, and show/hide context buttons. + +### Main App + +The production app already has most of the infrastructure, but at file granularity: + +- `src/server/diff-store.ts` owns git state for the active project: + - current dirty files, + - per-file patch loading through `readPatch`, + - branch metadata/history, + - commit, push, discard, ignore flows. +- `src/shared/protocol.ts` has websocket subscriptions and commands: + - `project-git` snapshot for `ChatDiffSnapshot`, + - `project.readDiffPatch`, + - `chat.refreshDiffs`, + - commit and branch commands. +- `src/server/ws-router.ts` routes websocket commands to `DiffStore`. +- `src/server/codex-app-server.ts` already wraps `codex app-server` for chat and quick structured responses, but it currently does not expose `item/agentMessage/delta` to callers. +- `src/client/app/useKannaState.ts` subscribes to `project-git`. +- `src/client/app/ChatPage/useChatPageSidebarActions.ts` owns right-sidebar actions such as patch loading, refresh, discard, commit, branch switching. +- `src/client/components/chat-ui/RightSidebar.tsx` already contains the friend's intended UI structure: + - `Raw Diff` panel, + - `AI Order` panel, + - `Summary` panel, + - panel toggles, + - commit controls. + +The current `RightSidebar` AI behavior is fake: + +- `buildPrototypeAiOrderedDiff()` hashes file paths to produce deterministic but meaningless ordering. +- `buildPrototypeDiffSummary()` generates generic file-level summaries. +- Tests explicitly call this "fake AI ordering". + +This is the main replacement point. + +## Integration Direction + +Do not bring over the standalone HTTP/SSE app from `ui-test`. + +Instead: + +- Port the reusable diff-analysis logic into TypeScript under `src/shared` and `src/server`. +- Use the existing websocket protocol rather than SSE. +- Use the existing active project/chat context rather than a free-form project path input. +- Keep `DiffStore` as the owner of git patch retrieval. +- Add a small per-project diff analysis state manager for transient AI analysis runs. +- Replace only the fake AI data path in the right sidebar. Keep the friend's panel layout. + +## Product Scope + +Initial production scope should include current worktree changes and a separate current-branch-vs-default-branch comparison. + +The `ui-test` presets (`lastCommit`, `working`, `staged`, `mainBranch`, `custom`) are useful for the prototype, but the production sidebar is centered on current uncommitted project changes. Add range selection later if needed. + +Recommended first behavior: + +- `Analyze` runs on the currently selected files in the Changes sidebar. +- If no files are selected, disable the action. +- If all files are selected, the analysis is effectively for the full current diff. +- Invalidate or mark the analysis stale when any analyzed file's `patchDigest` changes. +- Add a compare mode control for: + - local worktree changes, which keeps the existing commit/discard/ignore behavior; + - current branch against the default branch, labeled from the repository default branch when available and falling back to `main`. +- Branch comparison should diff `HEAD` against the merge base with the default branch and should not expose worktree-only actions such as discard, ignore, or commit. +- Analysis, raw patch loading, selected paths, and staleness keys must all be scoped to the active compare mode so a local-worktree analysis is not reused for the branch-vs-main view. + +## Requested UX Revision + +The integrated sidebar should now use tab-first diff navigation instead of treating every panel as an independent toggle. + +### Diff View Tabs + +Replace the existing `Raw`, `AI`, and `Summary` independent toggles with tabs: + +- `Raw` +- `Reordered` +- `Natural Language` +- `Summary` + +Default behavior: + +- Only one tab is visible at a time. +- Clicking a tab switches to that tab and hides the others. +- Keep a simple `Multi` toggle beside the tabs for side-by-side review. +- Add spacing after `Multi` so it reads as a separate mode switch, not another diff tab. +- When `Multi` is off, selecting a tab collapses back to a single visible panel. +- When `Multi` is on, tab buttons can toggle multiple visible panels, but at least one panel should remain visible. +- When `Multi` is on, each visible panel should own its own vertical scroll container. +- When `Multi` is off, keep the current single scroll container behavior so sticky raw diff headers and the overall pane feel unchanged. + +### Split AI Output + +The old `AI` panel should become two separate views: + +- `Reordered`: show only the diff hunk cards in Codex's reordered sequence. Do not show natural-language descriptions in this view. +- `Natural Language`: show the one-sentence description for each hunk, in the same reordered sequence. Do not repeat the diff content here. + +`Summary` should focus on the overall generated summary instead of duplicating the per-hunk descriptions. + +Do not call reordered hunks `steps` or `hunks` in the UI. Label them as `Block 1`, `Block 2`, etc., and avoid repeating the generated trailing `block N` suffix in each source title. + +### Analysis Triggering And Staleness + +Remove the per-panel `Analyze` buttons from the reordered, natural-language, and summary views. + +New behavior: + +- When the user enters the Changes/diff view with selected files and no analysis for the current request key, start analysis automatically. +- The one analysis run generates all derived views: reordered hunks, natural-language descriptions, and summary. +- If selected files or their patch digests change after analysis, mark the generated result as stale instead of auto-rerunning. +- Show one yellow `Stale` button in the diff toolbar for stale results. +- On hover, the stale button should read `Analyze`; clicking it reruns analysis for the current selected files. +- While analysis is running, show a compact global running/cancel affordance in the toolbar rather than panel-level analyze buttons. + +### Commit Form Placement + +The commit message and description inputs should be pinned to the bottom of the right sidebar, outside the scrollable diff content. + +Requirements: + +- The diff panels should scroll independently above the commit form. +- The commit form should no longer float in the middle of the pane with a gradient overlay. +- Keep the existing generate/commit behavior and context-menu `Commit Only` fallback. +- Hide the commit form when viewing the current-branch-vs-default-branch comparison because that view can include already-committed branch changes. + +## Data Model + +Add shared types in a new file, likely `src/shared/diff-analysis.ts`. + +Recommended shapes: + +```ts +export type DiffAnalysisStatus = + | "idle" + | "starting" + | "running" + | "cancelling" + | "completed" + | "failed" + | "interrupted" + +export interface DiffAnalysisStats { + files: number + hunks: number + additions: number + deletions: number + lines: number +} + +export interface DiffAnalysisSourceBlock { + id: string + file: string + oldFile: string + newFile: string + title: string + diff: string + contextBefore: string[] + contextAfter: string[] +} + +export interface DiffAnalysisNote { + id: string + diff: string + description: string +} + +export interface ParsedDiffAnalysis { + hunks: DiffAnalysisNote[] + summary: string + partial: string + isComplete: boolean +} + +export interface DiffAnalysisSnapshot { + projectId: string + status: DiffAnalysisStatus + statusText: string + startedAt: string | null + completedAt: string | null + error: string | null + selectedPaths: string[] + requestKey: string | null + diffStats: DiffAnalysisStats | null + sourceBlocks: DiffAnalysisSourceBlock[] + parsed: ParsedDiffAnalysis + plan: Array<{ step: string; status: "pending" | "inProgress" | "completed" }> +} +``` + +`requestKey` should be derived from selected paths plus current patch digests, so the client can tell whether a completed analysis still matches the visible diff. + +## Shared Code To Port + +Port these files from `ui-test/shared` to TypeScript: + +- `diffHunks.js` -> `src/shared/diff-analysis-hunks.ts` +- `diffStats.js` -> `src/shared/diff-analysis-stats.ts` +- `parseAgentResponse.js` -> `src/shared/diff-analysis-parser.ts` + +Keep the marker format from `ui-test` for the first integration because it supports useful partial parsing while the model streams. + +Important changes during the port: + +- Add TypeScript interfaces and tests. +- Keep hunk IDs as `H001`, `H002`, etc. +- Keep model context at 2 lines and expandable UI context at 10 lines initially. +- Make parser tolerant of partial output, duplicate notes, and unknown IDs. +- Avoid including full raw model output in websocket snapshots unless needed for debugging. + +## Server Plan + +### 1. Extend Patch Retrieval + +`src/server/diff-store.ts` currently has `readPatch()` for one path and uses `createPatch()` with `--unified=3`. + +Add server helpers for analysis: + +- `readPatchesForAnalysis({ projectPath, paths, contextLines })` +- or `readCombinedPatch({ projectPath, paths, contextLines })` + +Requirements: + +- Validate every path with `normalizeRepoRelativePath`. +- Confirm every path is still dirty. +- Support tracked, untracked, added, deleted, and renamed files. +- Allow the caller to request larger context, e.g. 12 lines, so the hunk splitter can keep 2 model lines plus 10 expandable viewer lines. +- Reuse `createPatch()` where possible, but make it accept a `contextLines` argument. +- Skip or clearly report binary/unreadable files; do not send binary blobs to Codex. + +### 2. Add Diff Analysis Manager + +Create a new server module, likely `src/server/diff-analysis-store.ts`. + +Responsibilities: + +- Maintain in-memory `DiffAnalysisSnapshot` by `projectId`. +- Start one analysis at a time per project. +- Cancel/interrupt an active analysis. +- Reset analysis when project diffs are refreshed and patch digests no longer match. +- Expose `getProjectSnapshot(projectId)`. +- Expose an `onChange(projectId)` callback or integrate with router broadcasts. + +The manager should not replace `DiffStore`; it should call into `DiffStore` for patches and git state. + +### 3. Use Existing WebSocket Routing + +Extend `src/shared/protocol.ts`: + +- Add subscription: + +```ts +| { type: "project-diff-analysis"; projectId: string } +``` + +- Add commands: + +```ts +| { type: "chat.analyzeDiff"; chatId: string; paths: string[] } +| { type: "chat.cancelDiffAnalysis"; chatId: string } +``` + +- Add snapshot: + +```ts +| { type: "project-diff-analysis"; data: DiffAnalysisSnapshot | null } +``` + +Then update `src/server/ws-router.ts`: + +- Include `projectDiffAnalysis` in subscription counting and filtered broadcast logic. +- Return analysis snapshots from `createEnvelope`. +- Route `chat.analyzeDiff` and `chat.cancelDiffAnalysis`. +- Broadcast only the affected project's analysis snapshot during analysis updates. + +### 4. Codex Integration + +The `ui-test` server listens to raw app-server notifications including: + +- `turn/plan/updated` +- `item/started` +- `item/agentMessage/delta` +- `item/completed` +- `turn/completed` + +The production `src/server/codex-app-server.ts` currently maps app-server output into chat transcript events and does not expose agent-message deltas. + +There are two viable implementation paths: + +1. Preferred full-parity path: + - Extend `src/server/codex-app-server-protocol.ts` with `item/agentMessage/delta`. + - Extend `CodexAppServerManager` with a purpose-built `startAnalysisTurn()` or callback-based API that emits plan updates, deltas, completion, and errors without writing to chat transcripts. + - Let `DiffAnalysisStore` update its parsed snapshot as deltas arrive. + +2. Faster MVP path: + - Use `QuickResponseAdapter` or `CodexAppServerManager.generateStructured()` for a non-streaming result. + - Populate the AI and Summary panels after completion only. + - Add streaming later by exposing deltas. + +The recommended path is full parity because the proof of work's main UX value is live progress and partial hunk notes. + +### 5. Prompt + +Move `buildAnalysisPrompt()` from `ui-test/server.js` into the new analysis server module. + +Keep the core instructions: + +- The model receives numbered change blocks. +- It must order by data flow or dependency order. +- It must write one sentence per block. +- It must write a concise 3-5 sentence total summary. +- It must not repeat diff content. +- It must output only marker blocks. + +Add production-specific constraints: + +- If a source block is unimportant boilerplate, still include it with a concise note. +- Do not invent files or IDs. +- Include every source block exactly once. + +## Client Plan + +### 1. Subscribe To Analysis State + +Update `src/client/app/useKannaState.ts`: + +- Subscribe to `{ type: "project-diff-analysis", projectId: activeProjectId }`. +- Store the latest `diffAnalysisSnapshot`. +- Pass it into `ChatPage`. + +### 2. Add Sidebar Actions + +Update `src/client/app/ChatPage/useChatPageSidebarActions.ts`: + +- Add `handleAnalyzeDiff(paths: string[])`. +- Add `handleCancelDiffAnalysis()`. +- Optionally refresh diffs before analysis to reduce stale patch risk. +- Reuse the active chat ID for routing, like commit and branch commands. + +### 3. Replace Prototype AI Data + +Update `src/client/components/chat-ui/RightSidebar.tsx`: + +- Remove `PrototypeAiOrderedDiffEntry`. +- Remove `hashDiffPath()`. +- Remove `buildPrototypeDiffSummary()`. +- Remove or replace `buildPrototypeAiOrderedDiff()`. +- Add props for: + - `diffAnalysis`, + - `onAnalyzeDiff`, + - `onCancelDiffAnalysis`. + +The `Raw Diff` panel should keep using existing `DiffFileCard`. + +The `AI Order` panel should render ordered hunk-level entries: + +- Match `parsed.hunks[*].id` to `sourceBlocks`. +- Render the block title/file. +- Render the one-sentence description. +- Render the block diff. +- Offer `Show 10 lines before` / `Show 10 lines after` buttons using `contextBefore` and `contextAfter`. +- Show status states: + - no analysis yet, + - starting, + - running/streaming, + - completed, + - failed, + - stale. + +The `Summary` panel should render: + +- Overall summary from `parsed.summary`. +- Ordered one-line notes from `parsed.hunks`. +- A placeholder/CTA when no analysis has run. +- A stale warning when current selected files no longer match the analysis `requestKey`. + +### 4. Fit The Existing UI + +Keep the friend's layout choices: + +- Keep the `Raw`, `AI`, and `Summary` panel toggles. +- Keep the branch toolbar and commit controls. +- Keep the commit checkbox flow. +- Add analysis controls inside the `AI Order` and `Summary` panel headers, not as a separate top-level page. + +Recommended controls: + +- `Analyze` when idle or stale. +- `Refresh` when completed and still valid. +- `Cancel` while running. +- Small status text: `Analyzing 12 change blocks`, `Streaming notes`, `Analysis complete`, or error message. + +### 5. Rendering Strategy + +Create a small component rather than bloating `RightSidebar.tsx` further: + +- `src/client/components/chat-ui/DiffAnalysisPanel.tsx` +- or `src/client/components/chat-ui/DiffAnalysisCard.tsx` + +This component should own: + +- context expansion local state, +- matching parsed notes to source blocks, +- hunk-level diff rendering. + +Reuse `PatchDiff` if it renders partial hunk patches correctly. If it struggles with split blocks or inserted context, port the simple custom renderer from `ui-test/public/app.js` because it is predictable and small. + +## Testing Plan + +### Shared Logic + +Add or port tests for: + +- `parseUnifiedDiffHunks` +- `computeDiffStats` +- `parseAgentResponse` + +Use the existing `ui-test/test/*.test.js` cases as the source. + +Target files: + +- `src/shared/diff-analysis-hunks.test.ts` +- `src/shared/diff-analysis-stats.test.ts` +- `src/shared/diff-analysis-parser.test.ts` + +### Server + +Add tests around `DiffStore`: + +- combined patch generation for multiple selected files, +- larger context line handling, +- untracked files, +- renamed files, +- invalid paths rejected. + +Add websocket router tests: + +- `project-diff-analysis` subscription returns a snapshot, +- `chat.analyzeDiff` routes to the analysis manager, +- `chat.cancelDiffAnalysis` routes cancellation, +- analysis broadcasts only affect the matching project subscription. + +Mock Codex/app-server for: + +- plan update, +- agent delta, +- completed message, +- failed turn, +- interrupted turn. + +### Client + +Update `RightSidebar.test.tsx`: + +- Remove expectations for fake prototype summaries. +- Assert `AI Order` renders real analysis notes from a supplied snapshot. +- Assert no-analysis, running, failed, completed, and stale states. +- Assert `Analyze` is disabled with zero selected files. +- Assert `Cancel` appears while running. + +Add focused tests for the new analysis panel component if it is split out. + +## Implementation Sequence + +1. Port shared parser/splitter/stats from `ui-test` into TypeScript and get tests passing. +2. Add analysis-specific patch retrieval to `DiffStore`. +3. Add `DiffAnalysisStore` with in-memory snapshots and a mocked runner. +4. Extend websocket protocol and router for analysis subscription and commands. +5. Wire `DiffAnalysisStore` into `startKannaServer()`. +6. Add Codex runner support: + - first with mocked tests, + - then with actual app-server plan/delta/completion handling. +7. Subscribe in `useKannaState` and pass state/actions through `ChatPage`. +8. Replace `RightSidebar` fake AI ordering with real analysis snapshot rendering. +9. Update/remove tests that assert fake summaries. +10. Run: + - `bun test` + - `bun run check` + - a manual local analysis on a repo with multi-file changes. + +## Cleanup After Integration + +After the production feature works: + +- Keep `ui-test` temporarily until parity is verified. +- Then either remove it or move only its fixtures into production tests. +- Remove fake prototype helpers from `RightSidebar.tsx`. +- Remove tests that mention "fake AI ordering". + +## Risks And Mitigations + +- Large diffs can exceed context limits. + - Mitigation: selected-file analysis, stats before send, max block/file count guard, clear error asking user to narrow selection. +- Streaming support is not yet exposed by production `CodexAppServerManager`. + - Mitigation: add the missing `item/agentMessage/delta` protocol first, or ship a non-streaming MVP if time is tight. +- Analysis can go stale while the user edits files. + - Mitigation: request keys from paths plus patch digests, and show a stale state instead of silently reusing old notes. +- File-level right-sidebar state does not map one-to-one to hunk-level analysis. + - Mitigation: keep raw file cards unchanged and make AI/Summary explicitly hunk-level. +- Binary or very large generated files may be present in the diff. + - Mitigation: skip unsupported files with a visible warning, and never send binary contents to the model. diff --git a/hackathon-site/.gitignore b/hackathon-site/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/hackathon-site/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/hackathon-site/README.md b/hackathon-site/README.md new file mode 100644 index 000000000..a36934d87 --- /dev/null +++ b/hackathon-site/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/hackathon-site/eslint.config.js b/hackathon-site/eslint.config.js new file mode 100644 index 000000000..4fa125da2 --- /dev/null +++ b/hackathon-site/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/hackathon-site/index.html b/hackathon-site/index.html new file mode 100644 index 000000000..bd6bf582c --- /dev/null +++ b/hackathon-site/index.html @@ -0,0 +1,16 @@ + + + + + + + better-diff + + +
+ + + diff --git a/hackathon-site/package.json b/hackathon-site/package.json new file mode 100644 index 000000000..13cb20aba --- /dev/null +++ b/hackathon-site/package.json @@ -0,0 +1,27 @@ +{ + "name": "hackathon-site", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "vite": "^8.0.4" + } +} diff --git a/hackathon-site/public/favicon.svg b/hackathon-site/public/favicon.svg new file mode 100644 index 000000000..6893eb132 --- /dev/null +++ b/hackathon-site/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hackathon-site/public/icons.svg b/hackathon-site/public/icons.svg new file mode 100644 index 000000000..e9522193d --- /dev/null +++ b/hackathon-site/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hackathon-site/src/App.css b/hackathon-site/src/App.css new file mode 100644 index 000000000..2507feb7d --- /dev/null +++ b/hackathon-site/src/App.css @@ -0,0 +1,357 @@ +.page { + width: 100%; +} + +.topbar, +.footer-nav, +.section, +.workflow { + width: min(820px, calc(100% - 48px)); + margin: 0 auto; +} + +.topbar, +.footer-nav { + display: flex; + align-items: center; +} + +.topbar { + justify-content: space-between; + gap: 20px; + padding: 28px 0 16px; + border-bottom: 1px solid var(--border); + transform: rotate(-0.4deg); +} + +.brand, +.nav a, +.footer-nav a { + color: var(--text-h); + text-decoration: none; + text-transform: lowercase; +} + +.brand { + font-size: 1.4rem; + transform: rotate(-2deg); +} + +.nav, +.footer-nav { + display: flex; + flex-wrap: wrap; + gap: 18px; +} + +.nav { + justify-content: flex-end; +} + +.nav a:nth-child(1) { transform: rotate(-3deg); } +.nav a:nth-child(2) { transform: rotate(2deg); } +.nav a:nth-child(3) { transform: rotate(-1deg); } +.nav a:nth-child(4) { transform: rotate(2.5deg); } + + +.section { + padding: 80px 0; +} + +.hero { + position: relative; + padding-top: 100px; + padding-bottom: 48px; + text-align: center; + overflow: visible; +} + +.hero-doodles { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; + color: var(--chalk); +} + +.hero > *:not(.hero-doodles) { + position: relative; + z-index: 1; +} + +.doodle { + position: absolute; + opacity: 0.35; +} + +.doodle-diff { + top: 20%; + left: -6%; + transform: rotate(-6deg); +} + +.doodle-magnify { + top: 12%; + right: -2%; + transform: rotate(8deg); +} + +.doodle-braces { + bottom: 8%; + left: 2%; + transform: rotate(4deg); +} + +.doodle-plusminus { + top: 50%; + right: 4%; + transform: rotate(-4deg); +} + +.doodle-file { + bottom: 12%; + right: -4%; + transform: rotate(6deg); +} + +.doodle-loop { + top: 6%; + left: 30%; + transform: rotate(-3deg); +} + +.eyebrow { + color: var(--muted); + font-size: 1.2rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + transform: rotate(-0.8deg); + margin-bottom: 12px; +} + +.marker { + background: var(--marker); + padding: 2px 6px; + border-radius: 4px; +} + +.shout { + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.zoom-in { + display: inline-block; + font-size: 1.15em; +} + +.zoom-out { + display: inline-block; + font-size: 0.78em; + opacity: 0.88; + transform: translateY(-0.06em) rotate(-2deg); +} + +.underline { + text-decoration-line: underline; + text-decoration-style: wavy; + text-decoration-thickness: 1.5px; + text-underline-offset: 0.16em; +} + +.hero h1 { + max-width: 20ch; + margin: 20px auto 0; +} + +.intro-body, +.body-copy { + max-width: 42rem; + margin: 20px auto 0; + text-align: center; + color: var(--text); +} + + +.statement { + text-align: center; +} + +.statement p { + font-size: clamp(1.6rem, 3.2vw, 2.6rem); + line-height: 1.12; + color: var(--text-h); + max-width: 16ch; + margin: 0 auto; +} + +.statement.large p { + max-width: 18ch; +} + + +.narrow { + max-width: 680px; +} + +.narrow h2 { + margin-top: 12px; +} + +.pain-list { + margin: 28px 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 18px; +} + +.pain-item { + position: relative; + padding-left: 28px; + text-align: left; +} + +.pain-item::before { + content: '—'; + position: absolute; + left: 0; + top: 0; + color: var(--muted); +} + + +.has-doodle { + position: relative; +} + +.section-doodle { + position: absolute; + color: var(--chalk); + opacity: 0.3; + pointer-events: none; +} + +.section-doodle.float-right { + right: -48px; + top: 50%; + transform: translateY(-50%) rotate(5deg); +} + +.section-doodle.float-left { + left: -48px; + top: 50%; + transform: translateY(-50%) rotate(-4deg); +} + +.sketch-arrow { + display: flex; + justify-content: center; + padding: 4px 0; + color: var(--muted); + opacity: 0.6; +} + + +.workflow { + display: grid; + gap: 24px; + padding: 0 0 80px; +} + +.demo-block { + display: grid; + gap: 22px; + justify-items: center; +} + +.demo-copy { + max-width: 680px; + text-align: center; +} + +.demo-copy h2 { + margin-top: 10px; +} + +.demo-copy p:last-child { + max-width: 560px; + margin: 12px auto 0; + color: var(--text); +} + +.demo-media { + width: min(720px, 100%); +} + +.demo-media img { + display: block; + width: 100%; + border-radius: 3px; + transform: rotate(var(--tilt, -0.8deg)); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4); +} + +.demo-block:nth-child(2n) .demo-media img { --tilt: 0.9deg; } +.demo-block:nth-child(3n) .demo-media img { --tilt: -1.2deg; } + + +.zoom-stack { + width: fit-content; + margin: 24px auto 0; + padding-left: 24px; + text-align: left; + color: var(--text-h); +} + +.zoom-stack li:nth-child(1) { transform: rotate(-1.5deg); } +.zoom-stack li:nth-child(2) { transform: rotate(0.8deg); } +.zoom-stack li:nth-child(3) { transform: rotate(-0.6deg); } +.zoom-stack li:nth-child(4) { transform: rotate(1.2deg); } + + +.wrap-up { + text-align: center; + padding-bottom: 100px; +} + +.footer-nav { + justify-content: center; + padding: 20px 0 48px; +} + +.footer-nav a:nth-child(1) { transform: rotate(-2deg); } +.footer-nav a:nth-child(2) { transform: rotate(1.5deg); } +.footer-nav a:nth-child(3) { transform: rotate(-1deg); } +.footer-nav a:nth-child(4) { transform: rotate(2deg); } + + +@media (max-width: 700px) { + .topbar { + flex-direction: column; + align-items: flex-start; + padding-top: 22px; + } + + .nav { + justify-content: flex-start; + } + + .section { + padding: 60px 0; + } + + .hero { + padding-top: 72px; + padding-bottom: 36px; + } + + .hero-doodles { + display: none; + } + + .section-doodle { + display: none; + } +} diff --git a/hackathon-site/src/App.jsx b/hackathon-site/src/App.jsx new file mode 100644 index 000000000..cf7be427a --- /dev/null +++ b/hackathon-site/src/App.jsx @@ -0,0 +1,340 @@ +import './App.css' + +const painPoints = [ + 'large diff block and its just formatter things, but interleaved with important small changes that i might miss', + 'i have to jump around multiple parts of the diff since diffs are alpahbetically ordered by file name, not diff blocks by the execution flow they appear', + 'fatigue creeps in by just seeing a 2000 line change to comb through', +] + +const workflow = [ + { + step: '1', + title: 'i treat each diff block as my unit', + image: + 'https://github.com/user-attachments/assets/c2bdedc0-a90f-440a-80be-fac173d86570', + }, + { + step: '2', + title: + 'i take diffs and get the llm to rearrange it in the execution / data flow order. (it splits up blocks within the files as well)', + image: + 'https://github.com/user-attachments/assets/520fab22-a49b-486f-a687-cd325d01ee74', + }, + { + step: '3', + title: + 'then i get a one liner description for each of the diff like "formatter changed" "anthropic key changed to openrouter"', + image: + 'https://github.com/user-attachments/assets/3aa60edd-d454-4f75-878c-e7228c56f5b9', + }, + { + step: '4', + title: + 'and then i get a pr level or natural language description of the entire diff', + image: + 'https://github.com/user-attachments/assets/033a2149-5510-4b6f-95d4-3090d15f16e9', + }, + { + step: '5', + title: + 'and read it all the way from the bottom to top, so that i go from the least information to detail as i need', + }, +] + +function HeroDoodles() { + return ( + + ) +} + +function SectionDoodle({ type = 'brackets', className = '' }) { + const doodles = { + brackets: ( + + + + ), + hash: ( + + + + + + + ), + eye: ( + + + + + ), + } + + return ( + + ) +} + +function SketchArrow({ rotate = 0, flip = false }) { + return ( +
+ + + + +
+ ) +} + +function App() { + return ( +
+
+ + better-diff + + +
+ +
+ +

better-diff

+

+ normal diff too much. +
+ what if diffs were{' '} + TLDRs +
+ i can zoom in and{' '} + zoom out of +

+
+ +
+

+ code review has been getting harder with the volume of code to go + through due to ai assisted code generation (its a good thing only) +
+ looking at diffs kind of became pointless. raw diffs as it is i mean. +

+
+ +
+

+ i have to aggressviely prioritize what i should go over and what i + can just skim through these days +

+
+ + + +
+ +

you might have come across issues like these

+
    + {painPoints.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ +
+

+ i have to zoom in on things that + need scrutiny but brush over things that are arbitary +

+
+ + + +
+

so something i did at work is

+
+ +
+ {workflow.map((item, i) => ( +
+
+

+ {item.step}. {item.title} +

+
+ {item.image ? ( +
+ +
+ ) : null} + {i < workflow.length - 1 && ( + + )} +
+ ))} +
+ +
+

+ i jump in and out out of my summaries, natural lang diffs, rearranged + diffs, and the raw diffs. +

+
+ + + +
+ +

+ this is the core idea, and i wanted this as a core primtive in my code + editor / agent workspace. the old diff view just feels slow to work + with. +

+
+ +
+ +

middle ground

+

+ in an ideal world i can blindly rely on the ai you wouldnt need this +

+

+ but in places where its no there yet, i felt like there needs to be a + middle ground, and i think this is it. +

+

+ this wasnt possible before this general intelligence being available. +

+
+ +
+

+ reordering diff blocks require some intelligence. +
+ converting them into natural language defo. +
+ making a flexible tldr of that, yeah. +

+
+ + + +
+

+ cant this be just a skill, i think most things could be represented by + a instruction and a text output. +

+

+ there is a seamless in and out experience i wanted and how it + integrates with other system +

+

+ text could represent this but i think its too inefficient of a + representation and is too much detail for me to handle at once +

+

i like boxes hiding away detail from me

+
+ + + +
+

dev flow

+
    +
  • we used codex for the most part obvs.
  • +
  • + we primarily worked on top of this ui :{' '} + + github.com/jakemor/kanna + {' '} + which works with codex app server part of codex cli installed + locally. +
  • +
  • + what we implemented is extending the diff viewer to have the the + phases, the llm part of it is all via the same app server. +
  • +
+
+ + +
+ ) +} + +export default App diff --git a/hackathon-site/src/assets/hero.png b/hackathon-site/src/assets/hero.png new file mode 100644 index 000000000..cc51a3d20 Binary files /dev/null and b/hackathon-site/src/assets/hero.png differ diff --git a/hackathon-site/src/assets/react.svg b/hackathon-site/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/hackathon-site/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/hackathon-site/src/assets/vite.svg b/hackathon-site/src/assets/vite.svg new file mode 100644 index 000000000..5101b674d --- /dev/null +++ b/hackathon-site/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/hackathon-site/src/index.css b/hackathon-site/src/index.css new file mode 100644 index 000000000..40a197edc --- /dev/null +++ b/hackathon-site/src/index.css @@ -0,0 +1,70 @@ +:root { + color-scheme: dark; + --bg: #090909; + --text: #d9d5cb; + --text-h: #fffdf6; + --muted: #a09888; + --border: rgba(250, 248, 242, 0.1); + --chalk: rgba(255, 253, 246, 0.38); + --marker: rgba(255, 220, 80, 0.22); + font-family: + 'Chalkboard SE', 'Marker Felt', 'Bradley Hand', 'Comic Sans MS', + 'Segoe Print', cursive; + line-height: 1.6; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: var(--text); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + font-size: 18px; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--bg); +} + +a { + color: inherit; +} + +#root { + width: 100%; + min-height: 100vh; +} + +h1, h2, h3, p { + margin: 0; +} + +h1, h2, h3 { + color: var(--text-h); + font-weight: 700; + letter-spacing: -0.02em; +} + +h1 { + font-size: clamp(2.8rem, 6vw, 5.2rem); + line-height: 1.08; +} + +h2 { + font-size: clamp(1.8rem, 3.5vw, 3rem); + line-height: 1.12; +} + +p, li { + font-size: 1.15rem; + line-height: 1.7; +} diff --git a/hackathon-site/src/main.jsx b/hackathon-site/src/main.jsx new file mode 100644 index 000000000..8db44aa8c --- /dev/null +++ b/hackathon-site/src/main.jsx @@ -0,0 +1,24 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import Lenis from 'lenis' +import 'lenis/dist/lenis.css' +import './index.css' +import App from './App.jsx' + +const lenis = new Lenis({ + autoRaf: true, + smoothWheel: true, + lerp: 0.08, +}) + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + lenis.destroy() + }) +} + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/hackathon-site/vite.config.js b/hackathon-site/vite.config.js new file mode 100644 index 000000000..8b0f57b91 --- /dev/null +++ b/hackathon-site/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/src/client/app/ChatPage/index.tsx b/src/client/app/ChatPage/index.tsx index 9cd13da12..49de74ef7 100644 --- a/src/client/app/ChatPage/index.tsx +++ b/src/client/app/ChatPage/index.tsx @@ -541,6 +541,8 @@ export function ChatPage() { handleCommitDiffs, handleSyncBranch, handleGenerateCommitMessage, + handleAnalyzeDiff, + handleCancelDiffAnalysis, handleInitializeGit, handleGetGitHubPublishInfo, handleCheckGitHubRepoAvailability, @@ -969,6 +971,7 @@ export function ChatPage() { return { projectId, diffs: state.chatDiffSnapshot ?? EMPTY_DIFF_SNAPSHOT, + diffAnalysis: state.diffAnalysisSnapshot, editorLabel: state.editorLabel, diffRenderMode, wrapLines: wrapDiffLines, @@ -986,6 +989,8 @@ export function ChatPage() { onCheckoutBranch: handleCheckoutBranch, onCreateBranch: handleCreateBranch, onGenerateCommitMessage: handleGenerateCommitMessage, + onAnalyzeDiff: handleAnalyzeDiff, + onCancelDiffAnalysis: handleCancelDiffAnalysis, onInitializeGit: handleInitializeGit, onGetGitHubPublishInfo: handleGetGitHubPublishInfo, onCheckGitHubRepoAvailability: handleCheckGitHubRepoAvailability, @@ -1002,6 +1007,8 @@ export function ChatPage() { handleCheckoutBranch, handleCloseRightSidebar, handleCommitDiffs, + handleAnalyzeDiff, + handleCancelDiffAnalysis, handleCopyDiffFilePath, handleCopyDiffRelativePath, handleCreateBranch, @@ -1023,6 +1030,7 @@ export function ChatPage() { setDiffRenderMode, setWrapDiffLines, state.chatDiffSnapshot, + state.diffAnalysisSnapshot, state.editorLabel, wrapDiffLines, ]) diff --git a/src/client/app/ChatPage/useChatPageSidebarActions.ts b/src/client/app/ChatPage/useChatPageSidebarActions.ts index c4416084c..3d6354f0b 100644 --- a/src/client/app/ChatPage/useChatPageSidebarActions.ts +++ b/src/client/app/ChatPage/useChatPageSidebarActions.ts @@ -9,6 +9,7 @@ import type { ChatMergeBranchResult, ChatMergePreviewResult, ChatSyncResult, + DiffComparisonMode, DiffCommitMode, DiffCommitResult, GitHubPublishInfo, @@ -98,14 +99,15 @@ export function useChatPageSidebarActions({ void state.handleCopyPath(filePath) }, [state.handleCopyPath]) - const handleLoadDiffPatch = useCallback(async (filePath: string) => { + const handleLoadDiffPatch = useCallback(async (args: { path: string; comparisonMode: DiffComparisonMode }) => { if (!projectId) { throw new Error("Project not found") } const result = await state.socket.command<{ patch: string }>({ type: "project.readDiffPatch", projectId, - path: filePath, + path: args.path, + comparisonMode: args.comparisonMode, }) return result.patch }, [projectId, state.socket]) @@ -274,6 +276,30 @@ export function useChatPageSidebarActions({ } }, [state.socket]) + const handleAnalyzeDiff = useCallback(async (args: { paths: string[]; comparisonMode: DiffComparisonMode }) => { + if (!projectId) { + return + } + + await state.socket.command({ + type: "project.analyzeDiff", + projectId, + paths: args.paths, + comparisonMode: args.comparisonMode, + }) + }, [projectId, state.socket]) + + const handleCancelDiffAnalysis = useCallback(async () => { + if (!projectId) { + return + } + + await state.socket.command({ + type: "project.cancelDiffAnalysis", + projectId, + }) + }, [projectId, state.socket]) + const handleInitializeGit = useCallback(async () => { const chatId = activeChatIdRef.current if (!chatId) { @@ -651,6 +677,8 @@ export function useChatPageSidebarActions({ handleCommitDiffs, handleSyncBranch, handleGenerateCommitMessage, + handleAnalyzeDiff, + handleCancelDiffAnalysis, handleInitializeGit, handleGetGitHubPublishInfo, handleCheckGitHubRepoAvailability, diff --git a/src/client/app/useKannaState.ts b/src/client/app/useKannaState.ts index abe200a71..4da1cad27 100644 --- a/src/client/app/useKannaState.ts +++ b/src/client/app/useKannaState.ts @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom" import { useShallow } from "zustand/react/shallow" import { APP_NAME } from "../../shared/branding" import { PROVIDERS, type AgentProvider, type AskUserQuestionAnswerMap, type ChatAttachment, type ChatDiffSnapshot, type ChatHistoryPage, type KeybindingsSnapshot, type LlmProviderSnapshot, type ModelOptions, type ProviderCatalogEntry, type QueuedChatMessage, type TranscriptEntry, type UpdateInstallResult, type UpdateSnapshot, type UserPromptEntry } from "../../shared/types" +import type { DiffAnalysisSnapshot } from "../../shared/diff-analysis" import { NEW_CHAT_COMPOSER_ID, type ComposerState, useChatPreferencesStore } from "../stores/chatPreferencesStore" import { useRightSidebarStore } from "../stores/rightSidebarStore" import { useTerminalLayoutStore } from "../stores/terminalLayoutStore" @@ -87,6 +88,22 @@ function sameQueuedMessages(left: ChatSnapshot["queuedMessages"] | null | undefi return left.every((message, index) => sameQueuedMessage(message, right[index]!)) } +function sameDiffFiles(left: ChatDiffSnapshot["files"], right: ChatDiffSnapshot["files"]) { + if (left.length !== right.length) return false + return left.every((file, index) => { + const other = right[index] + return Boolean(other) + && file.path === other.path + && file.changeType === other.changeType + && file.isUntracked === other.isUntracked + && file.additions === other.additions + && file.deletions === other.deletions + && file.patchDigest === other.patchDigest + && file.mimeType === other.mimeType + && file.size === other.size + }) +} + function sameDiffs(left: ChatDiffSnapshot | null | undefined, right: ChatDiffSnapshot | null | undefined) { if (left === right) return true if (!left || !right) return false @@ -115,19 +132,19 @@ function sameDiffs(left: ChatDiffSnapshot | null | undefined, right: ChatDiffSna && entry.tags.every((tag, tagIndex) => tag === other.tags[tagIndex]) }) if (!sameBranchHistory) return false - if (left.files.length !== right.files.length) return false - return left.files.every((file, index) => { - const other = right.files[index] - return Boolean(other) - && file.path === other.path - && file.changeType === other.changeType - && file.isUntracked === other.isUntracked - && file.additions === other.additions - && file.deletions === other.deletions - && file.patchDigest === other.patchDigest - && file.mimeType === other.mimeType - && file.size === other.size - }) + const leftComparison = left.defaultBranchComparison + const rightComparison = right.defaultBranchComparison + if (leftComparison || rightComparison) { + if (!leftComparison || !rightComparison) return false + if (leftComparison.mode !== rightComparison.mode) return false + if (leftComparison.status !== rightComparison.status) return false + if (leftComparison.baseBranchName !== rightComparison.baseBranchName) return false + if (leftComparison.baseRef !== rightComparison.baseRef) return false + if (leftComparison.headBranchName !== rightComparison.headBranchName) return false + if (leftComparison.message !== rightComparison.message) return false + if (!sameDiffFiles(leftComparison.files, rightComparison.files)) return false + } + return sameDiffFiles(left.files, right.files) } function shouldPreserveExistingProjectDiffs( @@ -477,6 +494,7 @@ export interface KannaState { updateSnapshot: UpdateSnapshot | null chatSnapshot: ChatSnapshot | null chatDiffSnapshot: ChatDiffSnapshot | null + diffAnalysisSnapshot: DiffAnalysisSnapshot | null keybindings: KeybindingsSnapshot | null llmProvider: LlmProviderSnapshot | null connectionStatus: SocketStatus @@ -557,6 +575,7 @@ export function useKannaState(activeChatId: string | null): KannaState { const [historyCursor, setHistoryCursor] = useState(null) const [hasOlderHistory, setHasOlderHistory] = useState(false) const [projectDiffSnapshots, setProjectDiffSnapshots] = useState>({}) + const [projectDiffAnalysisSnapshots, setProjectDiffAnalysisSnapshots] = useState>({}) const [keybindings, setKeybindings] = useState(null) const [llmProvider, setLlmProvider] = useState(null) const [connectionStatus, setConnectionStatus] = useState("connecting") @@ -892,6 +911,7 @@ export function useKannaState(activeChatId: string | null): KannaState { return currentDiffs }, [activeProjectId, projectDiffSnapshots]) + const diffAnalysisSnapshot = activeProjectId ? (projectDiffAnalysisSnapshots[activeProjectId] ?? null) : null useEffect(() => { if (!activeProjectId) { @@ -917,6 +937,20 @@ export function useKannaState(activeChatId: string | null): KannaState { return unsubscribe }, [activeProjectId, socket]) + + useEffect(() => { + if (!activeProjectId) { + return + } + + return socket.subscribe({ type: "project-diff-analysis", projectId: activeProjectId }, (snapshot) => { + setProjectDiffAnalysisSnapshots((current) => ({ + ...current, + [activeProjectId]: snapshot ?? null, + })) + setCommandError(null) + }) + }, [activeProjectId, socket]) useEffect(() => { logKannaState("active snapshot resolved", { routeChatId: activeChatId, @@ -1584,6 +1618,7 @@ export function useKannaState(activeChatId: string | null): KannaState { updateSnapshot, chatSnapshot, chatDiffSnapshot, + diffAnalysisSnapshot, keybindings, llmProvider, connectionStatus, diff --git a/src/client/components/chat-ui/RightSidebar.test.ts b/src/client/components/chat-ui/RightSidebar.test.ts index ec1d7557e..e5c16ecd4 100644 --- a/src/client/components/chat-ui/RightSidebar.test.ts +++ b/src/client/components/chat-ui/RightSidebar.test.ts @@ -1,55 +1,161 @@ import { describe, expect, mock, test } from "bun:test" -import { createElement } from "react" +import { createElement, type ComponentProps } from "react" import { renderToStaticMarkup } from "react-dom/server" -import { RightSidebar, canIgnoreDiffFile, canIgnoreDiffFolder } from "./RightSidebar" +import { createDiffAnalysisRequestKey } from "../../../shared/diff-analysis" +import { RightSidebar, canIgnoreDiffFile, canIgnoreDiffFolder, getDiffBlockDisplayLines, getInitialDiffComparisonMode, resolveDiffComparisonMode } from "./RightSidebar" import { TooltipProvider } from "../ui/tooltip" +type RightSidebarProps = ComponentProps + +const DEFAULT_DIFFS: RightSidebarProps["diffs"] = { + status: "unknown", + files: [], + branchHistory: { entries: [] }, +} + +function renderRightSidebar(overrides: Partial) { + const props: RightSidebarProps = { + projectId: "project-1", + diffs: DEFAULT_DIFFS, + diffAnalysis: null, + editorLabel: "Cursor", + diffRenderMode: "unified", + wrapLines: false, + onOpenFile: () => {}, + onOpenInFinder: () => {}, + onDiscardFile: () => {}, + onIgnoreFile: () => {}, + onIgnoreFolder: () => {}, + onCopyFilePath: () => {}, + onCopyRelativePath: () => {}, + onLoadPatch: async () => "", + onListBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }), + onPreviewMergeBranch: async () => ({ + currentBranchName: "main", + targetBranchName: "feature/test", + targetDisplayName: "feature/test", + status: "mergeable", + commitCount: 1, + hasConflicts: false, + message: "ready", + }), + onMergeBranch: async () => null, + onCheckoutBranch: async () => {}, + onCreateBranch: async () => {}, + onGenerateCommitMessage: async () => ({ subject: "", body: "" }), + onAnalyzeDiff: async () => {}, + onCancelDiffAnalysis: async () => {}, + onInitializeGit: async () => null, + onGetGitHubPublishInfo: async () => ({ + ghInstalled: false, + authenticated: false, + owners: [], + suggestedRepoName: "my-repo", + }), + onCheckGitHubRepoAvailability: async () => ({ available: false, message: "Unavailable" }), + onSetupGitHub: async () => null, + onCommit: async () => null, + onSyncWithRemote: async () => null, + onDiffRenderModeChange: () => {}, + onWrapLinesChange: () => {}, + onClose: () => {}, + ...overrides, + } + + return renderToStaticMarkup(createElement( + TooltipProvider, + null, + createElement(RightSidebar, props) + )) +} + describe("RightSidebar", () => { + test("defaults comparison mode to the default branch when it is the only available diff", () => { + expect(getInitialDiffComparisonMode({ + workingTreeFiles: [], + defaultBranchComparison: { + mode: "default_branch", + status: "ready", + baseBranchName: "main", + baseRef: "main", + headBranchName: "feature/current", + files: [{ + path: "src/branch-only.ts", + changeType: "modified", + isUntracked: false, + additions: 3, + deletions: 1, + patchDigest: "branch-digest", + }], + }, + })).toBe("default_branch") + }) + + test("keeps local comparison selectable after local changes are cleared", () => { + expect(resolveDiffComparisonMode("working_tree", { + diffsStatus: "ready", + defaultBranchComparison: { + mode: "default_branch", + status: "ready", + baseBranchName: "main", + baseRef: "main", + headBranchName: "feature/current", + files: [{ + path: "src/branch-only.ts", + changeType: "modified", + isUntracked: false, + additions: 3, + deletions: 1, + patchDigest: "branch-digest", + }], + }, + })).toBe("working_tree") + }) + + test("falls back to local comparison when branch comparison is unavailable", () => { + expect(resolveDiffComparisonMode("default_branch", { + diffsStatus: "ready", + defaultBranchComparison: undefined, + })).toBe("working_tree") + }) + + test("renders only hunk body lines for reordered diff blocks", () => { + expect(getDiffBlockDisplayLines(`diff --git a/conversation-extractor/pyproject.toml b/conversation-extractor/pyproject.toml +index 8012335..06a691a 100644 +--- a/conversation-extractor/pyproject.toml ++++ b/conversation-extractor/pyproject.toml +@@ -1,18 +1,25 @@ + [project] + name = "conversation-extractor" +-version = "0.1.0" ++version = "0.1.1"`)).toEqual([ + " [project]", + " name = \"conversation-extractor\"", + "-version = \"0.1.0\"", + "+version = \"0.1.1\"", + ]) + }) + test("defaults to history when there are no changes", () => { - const markup = renderToStaticMarkup(createElement( - TooltipProvider, - null, - createElement(RightSidebar, { - projectId: "project-1", - diffs: { - status: "ready", - branchName: "main", - defaultBranchName: "main", - files: [], - branchHistory: { - entries: [{ - sha: "abc123", - summary: "Initial commit", - description: "Set up the project", - authorName: "Kanna", - authoredAt: new Date(Date.now() - 60_000).toISOString(), - tags: ["v1.0.0"], - githubUrl: "https://github.com/acme/repo/commit/abc123", - }], - }, + const markup = renderRightSidebar({ + diffs: { + status: "ready", + branchName: "main", + defaultBranchName: "main", + files: [], + branchHistory: { + entries: [{ + sha: "abc123", + summary: "Initial commit", + description: "Set up the project", + authorName: "Kanna", + authoredAt: new Date(Date.now() - 60_000).toISOString(), + tags: ["v1.0.0"], + githubUrl: "https://github.com/acme/repo/commit/abc123", + }], }, - editorLabel: "Cursor", - diffRenderMode: "unified", - wrapLines: false, - onOpenFile: () => {}, - onOpenInFinder: () => {}, - onDiscardFile: () => {}, - onIgnoreFile: () => {}, - onIgnoreFolder: () => {}, - onCopyFilePath: () => {}, - onCopyRelativePath: () => {}, - onLoadPatch: async () => "", - onListBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }), - onCheckoutBranch: async () => {}, - onCreateBranch: async () => {}, - onGenerateCommitMessage: async () => ({ subject: "", body: "" }), - onCommit: async () => null, - onSyncWithRemote: async () => null, - onDiffRenderModeChange: () => {}, - onWrapLinesChange: () => {}, - onClose: () => {}, - }) - )) + }, + }) expect(markup).toContain("History") expect(markup).toContain("Initial commit") @@ -59,172 +165,179 @@ describe("RightSidebar", () => { test("defaults to changes when there are file changes", () => { const onClose = mock(() => {}) - const markup = renderToStaticMarkup(createElement( - TooltipProvider, - null, - createElement(RightSidebar, { - projectId: "project-1", - diffs: { + const diffs: RightSidebarProps["diffs"] = { + status: "ready", + branchName: "main", + defaultBranchName: "main", + behindCount: 3, + hasOriginRemote: true, + hasUpstream: true, + originRepoSlug: "acme/repo", + files: [{ + path: "src/app.ts", + changeType: "modified", + isUntracked: false, + additions: 1, + deletions: 1, + patchDigest: "digest-1", + }], + branchHistory: { entries: [] }, + } + const markup = renderRightSidebar({ diffs, onClose }) + + expect(markup).toContain("src/app.ts") + expect(markup).toContain("Raw Diff") + expect(markup).toContain("Reordered") + expect(markup).toContain("Natural Language") + expect(markup).toContain("Summary") + expect(markup).toContain("Multi") + expect(markup).not.toContain("AI Order") + expect(markup).not.toContain("bg-gradient-to-t") + expect(markup).toContain("Open branch switcher") + expect(markup).toContain("Pull") + expect(markup).toContain("3") + expect(markup).not.toContain("Publish Branch") + }) + + test("shows default branch comparison when it is the only diff", () => { + const markup = renderRightSidebar({ + diffs: { + status: "ready", + branchName: "feature/current", + defaultBranchName: "main", + files: [], + defaultBranchComparison: { + mode: "default_branch", status: "ready", - branchName: "main", - defaultBranchName: "main", - behindCount: 3, - hasOriginRemote: true, - hasUpstream: true, - originRepoSlug: "acme/repo", + baseBranchName: "main", + baseRef: "main", + headBranchName: "feature/current", files: [{ - path: "src/app.ts", + path: "src/branch-only.ts", changeType: "modified", isUntracked: false, - additions: 1, + additions: 3, deletions: 1, - patchDigest: "digest-1", + patchDigest: "branch-digest", }], - branchHistory: { entries: [] }, }, - editorLabel: "Cursor", - diffRenderMode: "unified", - wrapLines: false, - onOpenFile: () => {}, - onOpenInFinder: () => {}, - onDiscardFile: () => {}, - onIgnoreFile: () => {}, - onIgnoreFolder: () => {}, - onCopyFilePath: () => {}, - onCopyRelativePath: () => {}, - onLoadPatch: async () => "", - onListBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }), - onCheckoutBranch: async () => {}, - onCreateBranch: async () => {}, - onGenerateCommitMessage: async () => ({ subject: "", body: "" }), - onCommit: async () => null, - onSyncWithRemote: async () => null, - onDiffRenderModeChange: () => {}, - onWrapLinesChange: () => {}, - onClose, - }) - )) + branchHistory: { entries: [] }, + }, + }) - expect(markup).toContain("src/app.ts") - expect(markup).toContain("Open branch switcher") - expect(markup).toContain("Pull") - expect(markup).toContain("3") - expect(markup).not.toContain("Publish Branch") + expect(markup).toContain("src/branch-only.ts") + expect(markup).toContain("feature/current vs main") + expect(markup).not.toContain("Commit message") + expect(markup).not.toContain("Discard Changes") }) - test("renders the branch switcher affordance", () => { - const onClose = mock(() => {}) - const markup = renderToStaticMarkup(createElement( - TooltipProvider, - null, - createElement(RightSidebar, { + test("renders stale analysis affordance when the diff no longer matches", () => { + const files: RightSidebarProps["diffs"]["files"] = [{ + path: "src/model.ts", + changeType: "modified", + isUntracked: false, + additions: 1, + deletions: 1, + patchDigest: "digest-model", + }] + const requestKey = createDiffAnalysisRequestKey([{ + ...files[0]!, + patchDigest: "old-digest", + }], ["src/model.ts"]) + const markup = renderRightSidebar({ + diffs: { + status: "ready", + branchName: "main", + files, + branchHistory: { entries: [] }, + }, + diffAnalysis: { projectId: "project-1", - diffs: { status: "unknown", files: [], branchHistory: { entries: [] } }, - editorLabel: "Cursor", - diffRenderMode: "unified", - wrapLines: false, - onOpenFile: () => {}, - onOpenInFinder: () => {}, - onDiscardFile: () => {}, - onIgnoreFile: () => {}, - onIgnoreFolder: () => {}, - onCopyFilePath: () => {}, - onCopyRelativePath: () => {}, - onLoadPatch: async () => "", - onListBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }), - onCheckoutBranch: async () => {}, - onCreateBranch: async () => {}, - onGenerateCommitMessage: async () => ({ subject: "", body: "" }), - onCommit: async () => null, - onSyncWithRemote: async () => null, - onDiffRenderModeChange: () => {}, - onWrapLinesChange: () => {}, - onClose, - }) - )) + status: "completed", + statusText: "Analysis complete", + startedAt: "2026-04-16T00:00:00.000Z", + completedAt: "2026-04-16T00:00:01.000Z", + error: null, + selectedPaths: ["src/model.ts"], + requestKey, + diffStats: { + files: 1, + hunks: 1, + additions: 1, + deletions: 1, + lines: 6, + }, + sourceBlocks: [{ + id: "H001", + file: "src/model.ts", + oldFile: "src/model.ts", + newFile: "src/model.ts", + title: "H001 src/model.ts", + diff: `diff --git a/src/model.ts b/src/model.ts +--- a/src/model.ts ++++ b/src/model.ts +@@ -1 +1 @@ +-old ++new`, + contextBefore: [" before"], + contextAfter: [" after"], + }], + parsed: { + hunks: [{ + id: "H001", + diff: "", + description: "Updates the model contract before downstream consumers use it.", + }], + summary: "The model contract changes and should be reviewed before dependent code.", + partial: "", + isComplete: true, + }, + plan: [], + }, + }) + + expect(markup).toContain("Stale") + expect(markup).toContain("Analyze") + expect(markup).toContain("Reordered") + expect(markup).toContain("Natural Language") + expect(markup).toContain("Summary") + }) + + test("renders the branch switcher affordance", () => { + const markup = renderRightSidebar({}) expect(markup).toContain("Open branch switcher") }) test("shows push to github for an unpublished local branch without a remote", () => { - const markup = renderToStaticMarkup(createElement( - TooltipProvider, - null, - createElement(RightSidebar, { - projectId: "project-1", - diffs: { - status: "ready", - branchName: "feature/local-only", - defaultBranchName: "main", - hasUpstream: false, - files: [], - branchHistory: { entries: [] }, - }, - editorLabel: "Cursor", - diffRenderMode: "unified", - wrapLines: false, - onOpenFile: () => {}, - onOpenInFinder: () => {}, - onDiscardFile: () => {}, - onIgnoreFile: () => {}, - onIgnoreFolder: () => {}, - onCopyFilePath: () => {}, - onCopyRelativePath: () => {}, - onLoadPatch: async () => "", - onListBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }), - onCheckoutBranch: async () => {}, - onCreateBranch: async () => {}, - onGenerateCommitMessage: async () => ({ subject: "", body: "" }), - onCommit: async () => null, - onSyncWithRemote: async () => null, - onDiffRenderModeChange: () => {}, - onWrapLinesChange: () => {}, - onClose: () => {}, - }) - )) + const markup = renderRightSidebar({ + diffs: { + status: "ready", + branchName: "feature/local-only", + defaultBranchName: "main", + hasUpstream: false, + files: [], + branchHistory: { entries: [] }, + }, + }) expect(markup).toContain("Push to GitHub") expect(markup).not.toContain("PR") }) test("shows open pr for a published non-default branch", () => { - const markup = renderToStaticMarkup(createElement( - TooltipProvider, - null, - createElement(RightSidebar, { - projectId: "project-1", - diffs: { - status: "ready", - branchName: "feature/branch-switcher", - defaultBranchName: "main", - hasOriginRemote: true, - hasUpstream: true, - originRepoSlug: "acme/repo", - files: [], - branchHistory: { entries: [] }, - }, - editorLabel: "Cursor", - diffRenderMode: "unified", - wrapLines: false, - onOpenFile: () => {}, - onOpenInFinder: () => {}, - onDiscardFile: () => {}, - onIgnoreFile: () => {}, - onIgnoreFolder: () => {}, - onCopyFilePath: () => {}, - onCopyRelativePath: () => {}, - onLoadPatch: async () => "", - onListBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }), - onCheckoutBranch: async () => {}, - onCreateBranch: async () => {}, - onGenerateCommitMessage: async () => ({ subject: "", body: "" }), - onCommit: async () => null, - onSyncWithRemote: async () => null, - onDiffRenderModeChange: () => {}, - onWrapLinesChange: () => {}, - onClose: () => {}, - }) - )) + const markup = renderRightSidebar({ + diffs: { + status: "ready", + branchName: "feature/branch-switcher", + defaultBranchName: "main", + hasOriginRemote: true, + hasUpstream: true, + originRepoSlug: "acme/repo", + files: [], + branchHistory: { entries: [] }, + }, + }) expect(markup).toContain("Fetch") expect(markup).toContain("PR") diff --git a/src/client/components/chat-ui/RightSidebar.tsx b/src/client/components/chat-ui/RightSidebar.tsx index 181f0cbd2..63cb9fe67 100644 --- a/src/client/components/chat-ui/RightSidebar.tsx +++ b/src/client/components/chat-ui/RightSidebar.tsx @@ -6,7 +6,9 @@ import type { ChatBranchHistoryEntry, ChatBranchListEntry, ChatBranchListResult, + ChatDiffComparisonSnapshot, ChatDiffSnapshot, + DiffComparisonMode, DiffCommitMode, DiffCommitResult, ChatMergeBranchResult, @@ -14,6 +16,8 @@ import type { GitHubPublishInfo, GitHubRepoAvailabilityResult, } from "../../../shared/types" +import { createDiffAnalysisRequestKey, type DiffAnalysisSnapshot, type DiffAnalysisSourceBlock } from "../../../shared/diff-analysis" +import { extractPartialSummaryPreview } from "../../../shared/diff-analysis-parser" import { useStickyState } from "../../hooks/useStickyState" import { cn } from "../../lib/utils" import { useDiffCommitStore } from "../../stores/diffCommitStore" @@ -34,7 +38,61 @@ import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, Dia type DiffRenderMode = "unified" | "split" type DiffFile = ChatDiffSnapshot["files"][number] type SidebarViewMode = "changes" | "history" +type DiffPanelKey = "raw" | "reordered" | "natural" | "summary" const EMPTY_CHECKED_PATHS: Record = {} +const DEFAULT_DIFF_COMPARISON_MODE: DiffComparisonMode = "working_tree" + +const DEFAULT_DIFF_PANEL_VISIBILITY: Record = { + raw: true, + reordered: false, + natural: false, + summary: false, +} + +const DIFF_PANEL_OPTIONS: Array<{ value: DiffPanelKey; label: string }> = [ + { value: "raw", label: "Raw" }, + { value: "reordered", label: "Reordered" }, + { value: "natural", label: "Natural Language" }, + { value: "summary", label: "Summary" }, +] + +function createSingleDiffPanelVisibility(panel: DiffPanelKey): Record { + return { + raw: panel === "raw", + reordered: panel === "reordered", + natural: panel === "natural", + summary: panel === "summary", + } +} + +function createDiffPatchCacheKey(comparisonMode: DiffComparisonMode, path: string) { + return `${comparisonMode}\u0000${path}` +} + +export function getInitialDiffComparisonMode(args: { + workingTreeFiles: ChatDiffSnapshot["files"] + defaultBranchComparison?: ChatDiffComparisonSnapshot +}): DiffComparisonMode { + const defaultBranchComparisonFiles = args.defaultBranchComparison?.status === "ready" + ? args.defaultBranchComparison.files + : [] + return args.workingTreeFiles.length === 0 && defaultBranchComparisonFiles.length > 0 + ? "default_branch" + : DEFAULT_DIFF_COMPARISON_MODE +} + +export function resolveDiffComparisonMode( + currentMode: DiffComparisonMode, + args: { + diffsStatus: ChatDiffSnapshot["status"] + defaultBranchComparison?: ChatDiffComparisonSnapshot + } +): DiffComparisonMode { + if (currentMode === "default_branch" && !(args.diffsStatus === "ready" && args.defaultBranchComparison)) { + return DEFAULT_DIFF_COMPARISON_MODE + } + return currentMode +} function getDiffPreviewAttachment(projectId: string | null, file: DiffFile): ChatAttachment | null { if (!projectId || !file.mimeType || typeof file.size !== "number" || file.changeType === "deleted") { @@ -70,16 +128,19 @@ export interface DiffFileActions { interface RightSidebarProps extends DiffFileActions { projectId: string | null diffs: ChatDiffSnapshot + diffAnalysis: DiffAnalysisSnapshot | null editorLabel: string diffRenderMode: DiffRenderMode wrapLines: boolean - onLoadPatch: (path: string) => Promise + onLoadPatch: (args: { path: string; comparisonMode: DiffComparisonMode }) => Promise onListBranches: () => Promise onPreviewMergeBranch: (branch: ChatBranchListEntry) => Promise onMergeBranch: (branch: ChatBranchListEntry) => Promise onCheckoutBranch: (branch: ChatBranchListEntry) => Promise onCreateBranch: () => Promise onGenerateCommitMessage: (args: { paths: string[] }) => Promise<{ subject: string; body: string }> + onAnalyzeDiff: (args: { paths: string[]; comparisonMode: DiffComparisonMode }) => Promise + onCancelDiffAnalysis: () => Promise onInitializeGit: () => Promise onGetGitHubPublishInfo: () => Promise onCheckGitHubRepoAvailability: (args: { owner: string; name: string }) => Promise @@ -211,6 +272,400 @@ function formatFetchTooltip(isoTimestamp?: string) { return `Last fetched ${formatRelativeTime(isoTimestamp)}` } +function isAnalysisRunning(analysis: DiffAnalysisSnapshot | null) { + return analysis?.status === "starting" || analysis?.status === "running" || analysis?.status === "cancelling" +} + +function getAnalysisStatusText(analysis: DiffAnalysisSnapshot | null, isStale: boolean) { + if (isStale) return "Analysis is stale" + if (!analysis || analysis.status === "idle") return "Not analyzed" + if (analysis.status === "failed") return analysis.error || analysis.statusText + return analysis.statusText +} + +function classifyDiffLine(line: string) { + if (line.startsWith("diff --git ") || line.startsWith("---") || line.startsWith("+++")) { + return "bg-muted/60 text-muted-foreground font-medium" + } + if (line.startsWith("@@")) { + return "bg-accent text-muted-foreground" + } + if (line.startsWith("+")) { + return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" + } + if (line.startsWith("-")) { + return "bg-red-500/10 text-red-700 dark:text-red-300" + } + return "text-muted-foreground" +} + +export function getDiffBlockDisplayLines(diffText: string) { + const lines = String(diffText).split(/\r?\n/u) + while (lines.length > 0 && lines.at(-1) === "") { + lines.pop() + } + + const hunkHeaderIndex = lines.findIndex((line) => line.startsWith("@@")) + if (hunkHeaderIndex >= 0) { + return lines.slice(hunkHeaderIndex + 1) + } + + return lines.filter((line) => ( + !line.startsWith("diff --git ") + && !line.startsWith("index ") + && !line.startsWith("---") + && !line.startsWith("+++") + )) +} + +function DiffLine({ line, expanded = false }: { line: string; expanded?: boolean }) { + return ( +
+ {line} +
+ ) +} + +function DiffBlockView({ + block, + showBefore, + showAfter, +}: { + block: DiffAnalysisSourceBlock + showBefore: boolean + showAfter: boolean +}) { + const lines = getDiffBlockDisplayLines(block.diff) + const rows: ReactNode[] = [] + + if (showBefore) { + rows.push(...block.contextBefore.map((contextLine, contextIndex) => ( + + ))) + } + + rows.push(...lines.map((line, index) => )) + + if (showAfter) { + rows.push(...block.contextAfter.map((contextLine, contextIndex) => ( + + ))) + } + + return ( +
+ {rows} +
+ ) +} + +function formatDiffAnalysisBlockTitle(block: DiffAnalysisSourceBlock | undefined, fallbackId: string) { + return (block?.title ?? fallbackId).replace(/\s+block\s+\d+$/iu, "") +} + +function DiffAnalysisToolbarAction({ + analysis, + selectedPaths, + isStale, + onAnalyze, + onCancel, +}: { + analysis: DiffAnalysisSnapshot | null + selectedPaths: string[] + isStale: boolean + onAnalyze: () => void + onCancel: () => void +}) { + const running = isAnalysisRunning(analysis) + if (!running && !isStale && analysis?.status !== "failed") { + return null + } + + const disabled = running ? analysis?.status === "cancelling" : selectedPaths.length === 0 + return ( + + ) +} + +function DiffAnalysisReorderedPanel({ + analysis, + selectedPaths, + currentRequestKey, + scrollable = false, +}: { + analysis: DiffAnalysisSnapshot | null + selectedPaths: string[] + currentRequestKey: string | null + scrollable?: boolean +}) { + const [contextVisibility, setContextVisibility] = useState>({}) + + useEffect(() => { + setContextVisibility({}) + }, [analysis?.startedAt]) + + const isStale = Boolean(analysis?.requestKey && currentRequestKey && analysis.requestKey !== currentRequestKey) + const sourceBlocksById = new Map((analysis?.sourceBlocks ?? []).map((block) => [block.id, block])) + const notes = analysis?.parsed.hunks ?? [] + const orderedBlocks = notes + .map((note) => sourceBlocksById.get(note.id)) + .filter((block): block is DiffAnalysisSourceBlock => Boolean(block)) + const hasStarted = Boolean(analysis && analysis.status !== "idle") + const running = isAnalysisRunning(analysis) + + function toggleContext(blockId: string, side: "before" | "after") { + setContextVisibility((current) => ({ + ...current, + [blockId]: { + ...(current[blockId] ?? {}), + [side]: !(current[blockId]?.[side] ?? false), + }, + })) + } + + return ( +
+
+
+
Reordered
+
+ {getAnalysisStatusText(analysis, isStale)} +
+
+
+
+ {selectedPaths.length === 0 ? ( +
+ Select files to analyze. +
+ ) : orderedBlocks.length > 0 ? ( + orderedBlocks.map((block, index) => { + const visibility = contextVisibility[block.id] ?? {} + return ( +
+
+
+
Block {index + 1}
+
{formatDiffAnalysisBlockTitle(block, block.id)}
+
+
+ {block.id} +
+
+
+ + +
+ +
+ ) + }) + ) : hasStarted ? ( +
+ {running ? "Waiting for reordered blocks from Codex." : "No reordered blocks were returned."} +
+ ) : ( +
+ Analysis starts automatically for selected files. +
+ )} +
+
+ ) +} + +function DiffAnalysisNaturalLanguagePanel({ + analysis, + selectedPaths, + currentRequestKey, + scrollable = false, +}: { + analysis: DiffAnalysisSnapshot | null + selectedPaths: string[] + currentRequestKey: string | null + scrollable?: boolean +}) { + const isStale = Boolean(analysis?.requestKey && currentRequestKey && analysis.requestKey !== currentRequestKey) + const sourceBlocksById = new Map((analysis?.sourceBlocks ?? []).map((block) => [block.id, block])) + const notes = analysis?.parsed.hunks ?? [] + const hasStarted = Boolean(analysis && analysis.status !== "idle") + const running = isAnalysisRunning(analysis) + + return ( +
+
+
+
Natural Language
+
+ {getAnalysisStatusText(analysis, isStale)} +
+
+
+
+ {selectedPaths.length === 0 ? ( +
+ Select files to analyze. +
+ ) : notes.length > 0 ? ( + notes.map((note, index) => { + const block = sourceBlocksById.get(note.id) + return ( +
+
+
+ Block {index + 1} +
+
+
{formatDiffAnalysisBlockTitle(block, note.id)}
+
{note.description || "No description was provided."}
+
+
+
+ ) + }) + ) : hasStarted ? ( +
+ {running ? "Waiting for descriptions from Codex." : "No descriptions were returned."} +
+ ) : ( +
+ Analysis starts automatically for selected files. +
+ )} +
+
+ ) +} + +function DiffAnalysisSummaryPanel({ + analysis, + selectedPaths, + currentRequestKey, + scrollable = false, +}: { + analysis: DiffAnalysisSnapshot | null + selectedPaths: string[] + currentRequestKey: string | null + scrollable?: boolean +}) { + const isStale = Boolean(analysis?.requestKey && currentRequestKey && analysis.requestKey !== currentRequestKey) + const summary = analysis?.parsed.summary.trim() ?? "" + const recoveredSummary = analysis?.status === "completed" + ? extractPartialSummaryPreview(analysis?.parsed.partial ?? "") + : "" + const displaySummary = summary || recoveredSummary + const hasStarted = Boolean(analysis && analysis.status !== "idle") + const running = isAnalysisRunning(analysis) + + return ( +
+
+
+
Summary
+
+ {getAnalysisStatusText(analysis, isStale)} +
+
+
+
+ {displaySummary ? ( +
+ {displaySummary} +
+ ) : selectedPaths.length === 0 ? ( +
+ Select files to summarize. +
+ ) : hasStarted ? ( +
+ {running ? "Waiting for the summary from Codex." : "No summary was returned."} +
+ ) : ( +
+ Analysis starts automatically for selected files. +
+ )} +
+
+ ) +} + function CommitHistoryRow({ entry, isPendingPush = false }: { entry: ChatBranchHistoryEntry; isPendingPush?: boolean }) { const relativeTime = formatRelativeTime(entry.authoredAt) const isClickable = Boolean(entry.githubUrl) @@ -1111,6 +1566,8 @@ function DiffFileCard({ projectId, isCollapsed, isChecked, + checkboxLabel, + enableWorktreeActions, editorLabel, diffRenderMode, wrapLines, @@ -1127,6 +1584,8 @@ function DiffFileCard({ projectId: string | null isCollapsed: boolean isChecked: boolean + checkboxLabel?: string + enableWorktreeActions: boolean editorLabel: string diffRenderMode: DiffRenderMode wrapLines: boolean @@ -1195,7 +1654,7 @@ function DiffFileCard({ return ( -
+
{!isCollapsed ?