diff --git a/.plans/branch-environment-picker-in-chatview-input.md b/.plans/branch-environment-picker-in-chatview-input.md new file mode 100644 index 0000000000..2c1994d2c8 --- /dev/null +++ b/.plans/branch-environment-picker-in-chatview-input.md @@ -0,0 +1,74 @@ +# Branch/Environment Picker in ChatView Input + +## Summary + +Add a secondary toolbar below the ChatView input area (similar to Codex UI) that lets users select the target branch and environment mode (Local vs New worktree) before sending their first message. + +## UX + +- A toolbar appears **below** the input form (always visible when it's a git repo) +- Two controls: + 1. **Environment mode** (left side): toggles between "Local" and "New worktree" — **locked after first message** (no longer clickable, just shows current mode as label) + 2. **Branch picker** (right side): dropdown showing local branches — **always changeable**, even after messages are sent +- If not a git repo, the toolbar is hidden entirely (thread uses project cwd as-is) + +## Changes + +### 0. Install `@tanstack/react-query` in `apps/renderer` + +Add dependency + wrap app in `QueryClientProvider`. + +### 1. `apps/renderer/src/store.ts` — MODIFY + +Add a new action to the reducer: + +```ts +| { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null } +``` + +Reducer case updates `branch` and `worktreePath` on the thread. + +### 2. `apps/renderer/src/components/ChatView.tsx` — MODIFY + +**Fetch branches** via `useQuery`: + +```ts +const branchQuery = useQuery({ + queryKey: ["git-branches", activeProject?.cwd], + queryFn: () => api.git.listBranches({ cwd: activeProject!.cwd }), + enabled: !!activeProject, +}); +``` + +**Local state:** + +- `envMode: "local" | "worktree"` — environment mode (local component state) + +**UI:** Below the `
`, render a toolbar bar (hidden if `!branchQuery.data?.isRepo`): + +- Left side: env mode button ("Local" / "New worktree") — disabled after first message (locked in) +- Right side: branch dropdown from `branchQuery.data.branches` +- Both styled like existing model picker (small text, chevron, dropdown menus) + +**Behavior:** + +- Branch picker is always active — changing branch dispatches `SET_THREAD_BRANCH` immediately +- Env mode is only clickable when `activeThread.messages.length === 0`. After first message, it becomes a static label showing the locked-in mode +- On first send (`onSend`): if `envMode === "worktree"` and a branch is selected, call `api.git.createWorktree` before starting the session, then dispatch `SET_THREAD_BRANCH` with the worktreePath +- `ensureSession` already uses `activeThread.worktreePath ?? activeProject.cwd` + +### Files to modify + +1. `apps/renderer/package.json` — add `@tanstack/react-query` +2. `apps/renderer/src/main.tsx` (or App entry) — wrap in `QueryClientProvider` +3. `apps/renderer/src/store.ts` — add `SET_THREAD_BRANCH` action +4. `apps/renderer/src/components/ChatView.tsx` — branch/env picker UI with `useQuery` + +## Verification + +1. `turbo build` — compiles +2. Create a new thread → branch bar appears below input with "Local" + current branch +3. Change branch in dropdown → branch updates on thread +4. Toggle "New worktree" → send message → worktree created, session uses worktree cwd +5. After first message: env mode label locks to "Worktree" (not clickable), branch picker still works +6. Non-git project → no branch bar shown diff --git a/.plans/git-flows-integration-tests.md b/.plans/git-flows-integration-tests.md new file mode 100644 index 0000000000..70e233a008 --- /dev/null +++ b/.plans/git-flows-integration-tests.md @@ -0,0 +1,99 @@ +# Git Flows Integration Tests + +## Overview + +Real integration tests that run actual git commands against temporary repos. No mocking. + +## Step 1: Extract git functions into `apps/desktop/src/git.ts` + +The git functions (`listGitBranches`, `createGitWorktree`, `removeGitWorktree`, `createGitBranch`, `checkoutGitBranch`, `initGitRepo`) and their helper `runTerminalCommand` are currently private in `main.ts`. Extract them into a new `apps/desktop/src/git.ts` module with named exports. + +`main.ts` will import and re-use them — no behavior change, just moving code. + +**Files modified:** + +- `apps/desktop/src/git.ts` — new file with all git functions exported +- `apps/desktop/src/main.ts` — import from `./git` instead of defining inline + +## Step 2: Create `apps/desktop/src/git.test.ts` + +Integration tests using real temp git repos. Each test group creates a fresh temp directory with `git init`, makes commits, creates branches as needed, and cleans up after. + +### Setup/teardown pattern + +```ts +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + listGitBranches, + createGitBranch, + checkoutGitBranch, + createGitWorktree, + removeGitWorktree, + initGitRepo, +} from "./git"; + +// Helper: run a raw git command in a dir (for test setup, not under test) +// Helper: create an initial commit (git needs at least one commit for branches) +``` + +### Test groups + +**1. initGitRepo** + +- Creates a valid git repo in a temp dir +- listGitBranches reports `isRepo: true` after init + +**2. listGitBranches** + +- Returns `isRepo: false` for non-git directory +- Returns the current branch with `current: true` +- Sorts current branch first +- Lists multiple branches after creating them +- `isDefault` is false when no remote (no origin/HEAD) + +**3. checkoutGitBranch** + +- Checks out an existing branch (current flag moves) +- Throws when branch doesn't exist +- Throws when checkout would overwrite uncommitted changes (dirty working tree) + +**4. createGitBranch** + +- Creates a new branch (appears in listGitBranches) +- Throws when branch already exists + +**5. createGitWorktree + removeGitWorktree** + +- Creates a worktree directory at the expected path +- Worktree has the correct branch checked out +- Throws when branch is already checked out in another worktree +- removeGitWorktree cleans up the worktree + +**6. Full flow: local branch checkout** + +- init → commit → create branch → checkout → verify current + +**7. Full flow: worktree creation from selected branch** + +- init → commit → create branch → create worktree → verify worktree dir exists and has correct branch + +**8. Full flow: thread switching simulation** + +- init → commit → create branch-a, branch-b → checkout a → checkout b → checkout a → verify current matches + +**9. Full flow: checkout conflict** + +- init → commit → create branch → modify file (unstaged) → checkout other branch → expect error + +## Verification + +```bash +# Run the git integration tests +cd apps/desktop && bun run test + +# Or just the git test file +npx vitest run apps/desktop/src/git.test.ts +``` diff --git a/.plans/git-flows-test-plan.md b/.plans/git-flows-test-plan.md new file mode 100644 index 0000000000..45b86b622b --- /dev/null +++ b/.plans/git-flows-test-plan.md @@ -0,0 +1,103 @@ +# Git Flows Test Plan + +## Overview + +Add tests for git branch/worktree flows. Two files: + +1. **Extend** `apps/renderer/src/store.test.ts` — reducer tests for `SET_THREAD_BRANCH` +2. **Create** `apps/renderer/src/git-flows.test.ts` — flow logic tests + +All tests are pure Vitest unit tests (no React rendering). They test the reducer directly and simulate handler logic via sequential reducer dispatches + mocked API calls. + +## File 1: `apps/renderer/src/store.test.ts` (extend) + +Add `describe("SET_THREAD_BRANCH reducer")` with 6 tests: + +- Sets branch + worktreePath atomically +- Clears both to null +- Updates branch while preserving worktreePath +- Does not affect other threads (multi-thread state) +- No-op for nonexistent thread id +- Does not mutate messages, error, or session fields + +Uses existing `makeThread`, `makeState` factories. + +## File 2: `apps/renderer/src/git-flows.test.ts` (new) + +### Factories + +- `makeThread()`, `makeState()`, `makeSession()` — same pattern as store.test.ts +- `makeBranch()` — creates `GitBranch` objects +- `makeMessage()` — creates `ChatMessage` objects +- `makeGitApi()` — returns `{ checkout, createWorktree, createBranch, listBranches }` with `vi.fn()` mocks + +### Test groups (~30 tests total) + +**1. Local branch checkout flow** (2 tests) + +- Successful checkout → SET_THREAD_BRANCH updates branch +- Checkout failure → SET_ERROR, branch unchanged + +**2. Thread branch conflict on send** (3 tests) + +- Two threads maintain independent branch state after SET_ACTIVE_THREAD +- Branch state preserved through multiple thread switches + updates +- Checkout failure on thread switch sets error only on target thread + +**3. Worktree creation on send** (5 tests) + +- First message in worktree mode → createWorktree → SET_THREAD_BRANCH with worktreePath +- No worktree when messages already exist +- No worktree in local envMode +- No worktree when worktreePath already set +- createWorktree failure → SET_ERROR, send aborted, no messages pushed + +**4. Env mode locking** (4 tests) + +- envLocked=false when no messages +- envLocked=true with messages +- Transitions false→true after PUSH_USER_MESSAGE +- Remains true after SET_ERROR and UPDATE_SESSION + +**5. Auto-fill current branch** (3 tests) + +- Dispatches SET_THREAD_BRANCH when thread has no branch and current branch exists +- Does not overwrite existing branch +- No-op when no branch is marked current + +**6. Default branch detection** (2 tests) + +- isDefault flag on branch objects +- current and isDefault can be on different branches + +**7. Branch creation + checkout** (3 tests) + +- Successful create + checkout updates branch +- createBranch failure → error, branch unchanged +- checkout failure after successful create → error, branch unchanged + +**8. Session CWD resolution** (3 tests) + +- Uses worktreePath when available +- cwdOverride takes precedence over worktreePath +- Falls back to project cwd when no worktree + +**9. Error handling patterns** (4 tests) + +- SET_ERROR sets error on correct thread +- SET_ERROR with null clears error +- Error on one thread doesn't affect others +- Error cleared before successful branch operations + +## Verification + +```bash +# Run all renderer tests +cd apps/renderer && bun run test + +# Run just the new test file +npx vitest run apps/renderer/src/git-flows.test.ts + +# Run just the store tests +npx vitest run apps/renderer/src/store.test.ts +``` diff --git a/.plans/git-integration-branch-picker-worktrees.md b/.plans/git-integration-branch-picker-worktrees.md new file mode 100644 index 0000000000..b5b5e82e32 --- /dev/null +++ b/.plans/git-integration-branch-picker-worktrees.md @@ -0,0 +1,115 @@ +# Git Integration: Branch Picker + Worktrees + +## Summary + +Add git integration to let users start new threads from a specific branch, optionally creating a git worktree for isolated agent work. + +## UX Flow + +- **Left click** "+ New thread" → immediately creates a thread (current behavior, unchanged) +- **Right click** "+ New thread" → opens a context menu with git options: + - List of local branches → clicking one creates a thread on that branch (uses project cwd) + - Each branch has a "worktree" sub-option → creates a worktree, then creates thread with worktree as cwd +- When thread has a worktree, the agent session uses the worktree path as its cwd +- If git fails (not a repo), context menu shows "Not a git repository" disabled item + +## Changes + +### 1. `packages/contracts/src/git.ts` — CREATE + +New Zod schemas and types: + +- `gitListBranchesInputSchema` — `{ cwd: string }` +- `gitCreateWorktreeInputSchema` — `{ cwd: string, branch: string, path?: string }` +- `gitRemoveWorktreeInputSchema` — `{ cwd: string, path: string }` +- `gitBranchSchema` — `{ name: string, current: boolean }` +- Result types for each + +### 2. `packages/contracts/src/ipc.ts` — MODIFY + +- Add 3 IPC channels: `git:list-branches`, `git:create-worktree`, `git:remove-worktree` +- Add `git` namespace to `NativeApi` with `listBranches`, `createWorktree`, `removeWorktree` + +### 3. `packages/contracts/src/index.ts` — MODIFY + +- Add `export * from "./git"` + +### 4. `apps/desktop/src/main.ts` — MODIFY + +Add 3 IPC handlers + helper functions: + +- `listGitBranches()` — runs `git branch --no-color`, parses output into `{ name, current }[]` +- `createGitWorktree()` — runs `git worktree add `, defaults path to `../{repo}-worktrees/{branch}` +- `removeGitWorktree()` — runs `git worktree remove ` + +Reuses existing `runTerminalCommand()`. + +### 5. `apps/desktop/src/preload.ts` — MODIFY + +Add `git` namespace with 3 `ipcRenderer.invoke` calls. + +### 6. `apps/renderer/src/types.ts` — MODIFY + +Add to `Thread`: + +``` +branch: string | null +worktreePath: string | null +``` + +### 7. `apps/renderer/src/persistenceSchema.ts` — MODIFY + +- Add optional `branch`/`worktreePath` to persisted thread schema (`.nullable().optional()` for backwards compat) +- Add V3 schema, update union +- Update `hydrateThread` to default new fields to `null` +- Update `toPersistedState` to serialize new fields + +### 8. `apps/renderer/src/store.ts` — MODIFY + +- Update persisted state key to v3, keep v2 as legacy fallback + +### 9. `apps/renderer/src/components/Sidebar.tsx` — MODIFY (main UI work) + +- Keep existing left-click `handleNewThread` unchanged (immediate thread creation) +- Add `onContextMenu` handler to "+ New thread" buttons (both global and per-project) +- On right-click: fetch branches via `api.git.listBranches`, show a custom context menu +- Context menu items: branch names, each with a nested option to create with worktree +- Clicking a branch → creates thread with `branch` set, title = branch name +- Clicking "with worktree" → calls `api.git.createWorktree` first, then creates thread with `worktreePath` +- Show branch badge on thread list items +- If not a git repo, show "Not a git repository" as disabled menu item + +Context menu component: a positioned `
` with `position: fixed` anchored to the click position, dismissed on click-outside or Escape. Follows the existing dropdown pattern from ChatView's model picker. + +### 10. `apps/renderer/src/components/ChatView.tsx` — MODIFY + +- Line 157: use `activeThread.worktreePath ?? activeProject.cwd` as session cwd +- Show branch/worktree badge in header bar + +## Implementation Order + +1. `packages/contracts/src/git.ts` (new schemas) +2. `packages/contracts/src/ipc.ts` + `index.ts` (wire up channels) +3. `apps/desktop/src/main.ts` (git command handlers) +4. `apps/desktop/src/preload.ts` (bridge methods) +5. `apps/renderer/src/types.ts` (Thread type update) +6. `apps/renderer/src/persistenceSchema.ts` + `store.ts` (persistence migration) +7. `apps/renderer/src/components/Sidebar.tsx` (branch picker UI) +8. `apps/renderer/src/components/ChatView.tsx` (worktree cwd + badge) + +## Edge Cases + +- **Not a git repo**: `git branch` fails → context menu shows "Not a git repository" disabled item +- **Branch has slashes**: `feature/foo` → worktree dir becomes `feature-foo` +- **Worktree exists**: git error surfaces to user via inline error message in context menu +- **No persistence breakage**: `.nullable().optional()` fields parse fine with old data + +## Verification + +1. `turbo build` — confirm contracts/desktop/renderer all compile +2. Launch app, add a project pointing to a git repo +3. Click "+ New thread" → verify branch list loads +4. Select a branch, click Start → thread created with branch in title +5. Enable worktree checkbox, pick branch, Start → verify worktree directory created on disk +6. Send a message in worktree thread → verify agent runs in worktree cwd +7. Add a non-git project → verify graceful error, can still create thread diff --git a/AGENTS.md b/AGENTS.md index 7f5148fce0..affea58d01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,13 @@ # CLAUDE.md ## Project Snapshot + T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon). This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. ## Core Priorities + 1. Performance first. 2. Reliability first. 3. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams). @@ -13,23 +15,28 @@ This repository is a VERY EARLY WIP. Proposing sweeping changes that improve lon If a tradeoff is required, choose correctness and robustness over short-term convenience. ## Package Roles + - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. - `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. ## Codex App Server (Important) + T3 Code is currently Codex-first. The server starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events to the browser through WebSocket push messages. How we use it in this codebase: + - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. - Web app consumes provider event streams via WebSocket push on channel `providers.event`. Docs: + - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server ## Reference Repos + - Open-source Codex repo: https://github.com/openai/codex - Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor diff --git a/README.md b/README.md index 1f18c9d300..93f955361e 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ npx t3 ## Scripts -- `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner. -- `bun run dev:server` — Starts just the WebSocket server (uses tsx for TS execution). +- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode. +- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution). - `bun run dev:web` — Starts just the Vite dev server for the web app. - `bun run start` — Runs the production server (serves built web app as static files). - `bun run build` — Builds contracts, web app, and server through Turbo. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9fc5d44ac8..d6afd21ce9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,10 +4,10 @@ "private": true, "main": "dist-electron/main.js", "scripts": { - "dev": "bun run --cwd ../server build && concurrently -k -n BUNDLE,WEB,ELECTRON \"bun run dev:bundle\" \"bun run --cwd ../web dev\" \"bun run dev:electron\"", - "dev:bundle": "tsup --watch", + "dev": "bun run --parallel dev:bundle dev:electron", + "dev:bundle": "tsdown --watch", "dev:electron": "bun run scripts/dev-electron.mjs", - "build": "tsup", + "build": "tsdown", "start": "electron dist-electron/main.js", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", @@ -18,11 +18,10 @@ }, "devDependencies": { "@types/node": "^22.10.2", - "concurrently": "^9.1.2", "electronmon": "^2.0.2", - "tsup": "^8.3.5", + "tsdown": "^0.20.3", "typescript": "^5.7.3", - "vitest": "^3.0.0", + "vitest": "^4.0.0", "wait-on": "^8.0.2" } } diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 0c49d1e586..dacbb99c66 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -10,7 +10,7 @@ await waitOn({ `tcp:${port}`, "file:dist-electron/main.js", "file:dist-electron/preload.js", - "file:../server/dist/index.js", + "file:../server/dist/index.mjs", ], }); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 895d18b8b6..883da7203a 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -1,16 +1,12 @@ -import { execSync, spawn } from "node:child_process"; +import { spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); -const rootDir = resolve(__dirname, "../../.."); const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); const mainJs = resolve(desktopDir, "dist-electron/main.js"); -console.log("Building contracts + web + server + desktop..."); -execSync("bun run build", { cwd: rootDir, stdio: "inherit" }); - console.log("\nLaunching Electron smoke test..."); const child = spawn(electronBin, [mainJs], { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1315aeb08a..eada6aae77 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,7 +14,7 @@ fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const ROOT_DIR = path.resolve(__dirname, "../../.."); -const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); +const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -186,6 +186,7 @@ function createWindow(): BrowserWindow { if (isDevelopment) { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); + window.webContents.openDevTools({ mode: "detach" }); } else { if (!fs.existsSync(WEB_ENTRY)) { throw new Error(`Web bundle missing at ${WEB_ENTRY}`); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 893a95a419..0ca5bcaa76 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "electron"], - "lib": ["ES2023", "DOM"] + "lib": ["ES2023", "DOM", "esnext.disposable"] }, - "include": ["src", "tsup.config.ts"] + "include": ["src", "tsdown.config.ts"] } diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts new file mode 100644 index 0000000000..4cffbb2587 --- /dev/null +++ b/apps/desktop/tsdown.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsdown"; + +const shared = { + format: "cjs" as const, + outDir: "dist-electron", + sourcemap: true, + outExtensions: () => ({ js: ".js" }), +}; + +export default defineConfig([ + { + ...shared, + entry: ["src/main.ts"], + clean: true, + noExternal: ["@t3tools/contracts"], + }, + { + ...shared, + entry: ["src/preload.ts"], + }, +]); diff --git a/apps/desktop/tsup.config.ts b/apps/desktop/tsup.config.ts deleted file mode 100644 index f87ac594df..0000000000 --- a/apps/desktop/tsup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/main.ts", "src/preload.ts"], - format: "cjs", - outDir: "dist-electron", - sourcemap: true, - clean: true, - noExternal: ["@t3tools/contracts"], -}); diff --git a/apps/desktop/turbo.jsonc b/apps/desktop/turbo.jsonc new file mode 100644 index 0000000000..2ff803229e --- /dev/null +++ b/apps/desktop/turbo.jsonc @@ -0,0 +1,24 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist-electron/**"], + }, + "dev": { + "dependsOn": ["t3#build"], + "persistent": true, + }, + "start": { + "dependsOn": ["build", "@t3tools/web#build", "t3#build"], + "cache": false, + "persistent": true, + }, + "smoke-test": { + "dependsOn": ["build", "@t3tools/web#build", "t3#build"], + "cache": false, + "outputs": [], + }, + }, +} diff --git a/apps/server/package.json b/apps/server/package.json index d9b3af34a7..7ed3d16144 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,19 +2,17 @@ "name": "t3", "version": "0.1.0", "bin": { - "t3": "./dist/index.js" + "t3": "./dist/index.mjs" }, "files": [ "dist" ], "type": "module", - "main": "./dist/index.js", + "main": "./dist/index.mjs", "scripts": { - "dev": "VITE_DEV_SERVER_URL=http://localhost:5173 tsx src/index.ts", - "dev:desktop": "T3CODE_MODE=desktop T3CODE_NO_BROWSER=1 tsx src/index.ts", - "build": "tsup && node scripts/bundle-client.mjs", - "start": "node dist/index.js", - "start:desktop": "T3CODE_MODE=desktop T3CODE_NO_BROWSER=1 node dist/index.js", + "dev": "VITE_DEV_SERVER_URL=http://localhost:5173 bun run src/index.ts", + "build": "tsdown && node scripts/bundle-client.mjs", + "start": "node dist/index.mjs", "typecheck": "tsc --noEmit", "test": "vitest run" }, @@ -27,8 +25,7 @@ "@t3tools/web": "workspace:*", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", - "tsup": "^8.3.5", - "tsx": "^4.19.0", + "tsdown": "^0.20.3", "typescript": "^5.7.3" } } diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index b611e4d4de..7c87e4442c 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -57,17 +57,13 @@ describe("normalizeCodexModelSlug", () => { describe("isRecoverableThreadResumeError", () => { it("matches not-found resume errors", () => { expect( - isRecoverableThreadResumeError( - new Error("thread/resume failed: thread not found"), - ), + isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")), ).toBe(true); }); it("ignores non-resume errors", () => { expect( - isRecoverableThreadResumeError( - new Error("thread/start failed: permission denied"), - ), + isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")), ).toBe(false); }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index af1e67e36b..f4ed54ab15 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -26,9 +26,7 @@ interface PendingRequest { interface PendingApprovalRequest { requestId: string; jsonRpcId: string | number; - method: - | "item/commandExecution/requestApproval" - | "item/fileChange/requestApproval"; + method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; requestKind: ProviderRequestKind; threadId?: string; turnId?: string; @@ -122,16 +120,12 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } | } export function isRecoverableThreadResumeError(error: unknown): boolean { - const message = ( - error instanceof Error ? error.message : String(error) - ).toLowerCase(); + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); if (!message.includes("thread/resume")) { return false; } - return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => - message.includes(snippet), - ); + return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); } export interface CodexAppServerManagerEvents { @@ -209,14 +203,10 @@ export class CodexAppServerManager extends EventEmitter { + const result = await runTerminalCommand({ + command: `git ${command}`, + cwd, + timeoutMs: 10_000, + }); + if (result.code !== 0) { + throw new Error(`git ${command} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +/** Create a disposable temp directory that cleans up automatically. */ +async function makeTmpDir() { + const dir = await mkdtemp(path.join(tmpdir(), "git-test-")); + return { + path: dir, + [Symbol.asyncDispose]: async () => { + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +/** Create a repo with an initial commit so branches work. */ +async function initRepoWithCommit(cwd: string): Promise { + await initGitRepo({ cwd }); + await git(cwd, "config user.email 'test@test.com'"); + await git(cwd, "config user.name 'Test'"); + await writeFile(path.join(cwd, "README.md"), "# test\n"); + await git(cwd, "add ."); + await git(cwd, "commit -m 'initial commit'"); +} + +// ── Tests ── + +describe("git integration", () => { + // ── initGitRepo ── + + describe("initGitRepo", () => { + it("creates a valid git repo", async () => { + await using tmp = await makeTmpDir(); + await initGitRepo({ cwd: tmp.path }); + expect(existsSync(path.join(tmp.path, ".git"))).toBe(true); + }); + + it("listGitBranches reports isRepo: true after init + commit", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.isRepo).toBe(true); + expect(result.branches.length).toBeGreaterThanOrEqual(1); + }); + }); + + // ── listGitBranches ── + + describe("listGitBranches", () => { + it("returns isRepo: false for non-git directory", async () => { + await using tmp = await makeTmpDir(); + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.isRepo).toBe(false); + expect(result.branches).toEqual([]); + }); + + it("returns the current branch with current: true", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const result = await listGitBranches({ cwd: tmp.path }); + const current = result.branches.find((b) => b.current); + expect(current).toBeDefined(); + expect(current!.current).toBe(true); + }); + + it("sorts current branch first", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "aaa-first-alpha" }); + await createGitBranch({ cwd: tmp.path, branch: "zzz-last" }); + + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.branches[0]!.current).toBe(true); + }); + + it("lists multiple branches after creating them", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "feature-a" }); + await createGitBranch({ cwd: tmp.path, branch: "feature-b" }); + + const result = await listGitBranches({ cwd: tmp.path }); + const names = result.branches.map((b) => b.name); + expect(names).toContain("feature-a"); + expect(names).toContain("feature-b"); + }); + + it("isDefault is false when no remote exists", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.branches.every((b) => b.isDefault === false)).toBe(true); + }); + }); + + // ── checkoutGitBranch ── + + describe("checkoutGitBranch", () => { + it("checks out an existing branch", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "feature" }); + + await checkoutGitBranch({ cwd: tmp.path, branch: "feature" }); + + const result = await listGitBranches({ cwd: tmp.path }); + const current = result.branches.find((b) => b.current); + expect(current!.name).toBe("feature"); + }); + + it("throws when branch does not exist", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow(); + }); + + it("throws when checkout would overwrite uncommitted changes", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "other" }); + + // Create a conflicting change: modify README on current branch + await writeFile(path.join(tmp.path, "README.md"), "modified\n"); + await git(tmp.path, "add README.md"); + + // First, checkout other branch cleanly + await git(tmp.path, "stash"); + await checkoutGitBranch({ cwd: tmp.path, branch: "other" }); + await writeFile(path.join(tmp.path, "README.md"), "other content\n"); + await git(tmp.path, "add ."); + await git(tmp.path, "commit -m 'other change'"); + + // Go back to default branch + const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => !b.current, + )!.name; + await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); + + // Make uncommitted changes to the same file + await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n"); + + // Checkout should fail due to uncommitted changes + await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow(); + }); + }); + + // ── createGitBranch ── + + describe("createGitBranch", () => { + it("creates a new branch visible in listGitBranches", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "new-feature" }); + + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); + }); + + it("throws when branch already exists", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "dupe" }); + await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow(); + }); + }); + + // ── createGitWorktree + removeGitWorktree ── + + describe("createGitWorktree", () => { + it("creates a worktree with a new branch from the base branch", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "worktree-out"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + const result = await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "wt-branch", + path: wtPath, + }); + + expect(result.worktree.path).toBe(wtPath); + expect(result.worktree.branch).toBe("wt-branch"); + expect(existsSync(wtPath)).toBe(true); + expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); + + // Clean up worktree before tmp dir disposal + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + }); + + it("worktree has the new branch checked out", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "wt-check-dir"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "wt-check", + path: wtPath, + }); + + // Verify the worktree is on the new branch + const branchOutput = await git(wtPath, "branch --show-current"); + expect(branchOutput).toBe("wt-check"); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + }); + + it("throws when new branch name already exists", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "existing" }); + + const wtPath = path.join(tmp.path, "wt-conflict"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await expect( + createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "existing", + path: wtPath, + }), + ).rejects.toThrow(); + }); + + it("listGitBranches from worktree cwd reports worktree branch as current", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "wt-list-dir"); + const mainBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await createGitWorktree({ + cwd: tmp.path, + branch: mainBranch, + newBranch: "wt-list", + path: wtPath, + }); + + // listGitBranches from the worktree should show wt-list as current + const wtBranches = await listGitBranches({ cwd: wtPath }); + expect(wtBranches.isRepo).toBe(true); + const wtCurrent = wtBranches.branches.find((b) => b.current); + expect(wtCurrent!.name).toBe("wt-list"); + + // Main repo should still show the original branch as current + const mainBranches = await listGitBranches({ cwd: tmp.path }); + const mainCurrent = mainBranches.branches.find((b) => b.current); + expect(mainCurrent!.name).toBe(mainBranch); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + }); + + it("removeGitWorktree cleans up the worktree", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "wt-remove-dir"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "wt-remove", + path: wtPath, + }); + expect(existsSync(wtPath)).toBe(true); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + expect(existsSync(wtPath)).toBe(false); + }); + }); + + // ── Full flow: local branch checkout ── + + describe("full flow: local branch checkout", () => { + it("init → commit → create branch → checkout → verify current", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "feature-login" }); + await checkoutGitBranch({ cwd: tmp.path, branch: "feature-login" }); + + const result = await listGitBranches({ cwd: tmp.path }); + const current = result.branches.find((b) => b.current); + expect(current!.name).toBe("feature-login"); + }); + }); + + // ── Full flow: worktree creation from base branch ── + + describe("full flow: worktree creation", () => { + it("creates worktree with new branch from current branch", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + const wtPath = path.join(tmp.path, "my-worktree"); + const result = await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "feature-wt", + path: wtPath, + }); + + // Worktree exists + expect(existsSync(result.worktree.path)).toBe(true); + + // Main repo still on original branch + const mainBranches = await listGitBranches({ cwd: tmp.path }); + const mainCurrent = mainBranches.branches.find((b) => b.current); + expect(mainCurrent!.name).toBe(currentBranch); + + // Worktree is on the new branch + const wtBranch = await git(wtPath, "branch --show-current"); + expect(wtBranch).toBe("feature-wt"); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath }); + }); + }); + + // ── Full flow: thread switching simulation ── + + describe("full flow: thread switching (checkout toggling)", () => { + it("checkout a → checkout b → checkout a → current matches", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "branch-a" }); + await createGitBranch({ cwd: tmp.path, branch: "branch-b" }); + + // Simulate switching to thread A's branch + await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); + let branches = await listGitBranches({ cwd: tmp.path }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); + + // Simulate switching to thread B's branch + await checkoutGitBranch({ cwd: tmp.path, branch: "branch-b" }); + branches = await listGitBranches({ cwd: tmp.path }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); + + // Switch back to thread A + await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" }); + branches = await listGitBranches({ cwd: tmp.path }); + expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); + }); + }); + + // ── Full flow: checkout conflict ── + + describe("full flow: checkout conflict", () => { + it("uncommitted changes prevent checkout to a diverged branch", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await createGitBranch({ cwd: tmp.path, branch: "diverged" }); + + // Make diverged branch have different file content + await checkoutGitBranch({ cwd: tmp.path, branch: "diverged" }); + await writeFile(path.join(tmp.path, "README.md"), "diverged content\n"); + await git(tmp.path, "add ."); + await git(tmp.path, "commit -m 'diverge'"); + + // Actually, let's just get back to the initial branch explicitly + const allBranches = await listGitBranches({ cwd: tmp.path }); + const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; + await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); + + // Make local uncommitted changes to the same file + await writeFile(path.join(tmp.path, "README.md"), "local uncommitted\n"); + + // Attempt checkout should fail + await expect(checkoutGitBranch({ cwd: tmp.path, branch: "diverged" })).rejects.toThrow(); + + // Current branch should still be the initial one + const result = await listGitBranches({ cwd: tmp.path }); + expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); + }); + }); +}); diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts new file mode 100644 index 0000000000..34b3b0a0fa --- /dev/null +++ b/apps/server/src/git.ts @@ -0,0 +1,229 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { + GitCheckoutInput, + GitCreateBranchInput, + GitCreateWorktreeInput, + GitCreateWorktreeResult, + GitInitInput, + GitListBranchesInput, + GitListBranchesResult, + GitRemoveWorktreeInput, + TerminalCommandInput, + TerminalCommandResult, +} from "@t3tools/contracts"; + +/** Spawn git directly with an argv array — no shell, no quoting needed. */ +function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, 1_000).unref(); + }, timeoutMs); + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut }); + }); + }); +} + +export async function runTerminalCommand( + input: TerminalCommandInput, +): Promise { + const shellPath = + process.platform === "win32" + ? (process.env.ComSpec ?? "cmd.exe") + : (process.env.SHELL ?? "/bin/sh"); + + const args = + process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; + + return new Promise((resolve, reject) => { + const child = spawn(shellPath, args, { + cwd: input.cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => { + if (!child.killed) { + child.kill("SIGKILL"); + } + }, 1_000).unref(); + }, input.timeoutMs ?? 30_000); + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + + child.on("close", (code, signal) => { + clearTimeout(timeout); + resolve({ + stdout, + stderr, + code: code ?? null, + signal: signal ?? null, + timedOut, + }); + }); + }); +} + +export async function listGitBranches(input: GitListBranchesInput): Promise { + const result = await runGit(["branch", "--no-color"], input.cwd, 10_000); + + if (result.code !== 0) { + const stderr = result.stderr.trim(); + if (stderr.includes("not a git repository")) { + return { branches: [], isRepo: false }; + } + throw new Error(stderr || "git branch failed"); + } + + // Resolve the real default branch from the remote + const [defaultRef, worktreeList] = await Promise.all([ + runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000), + runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000), + ]); + const defaultBranch = + defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; + + // Build branch-name → worktree-path map from porcelain output. + // Only include worktrees whose directories still exist on disk (skip prunable/stale ones). + const worktreeMap = new Map(); + if (worktreeList.code === 0) { + let currentPath: string | null = null; + for (const line of worktreeList.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + currentPath = line.slice("worktree ".length); + if (!fs.existsSync(currentPath)) currentPath = null; + } else if (line.startsWith("branch refs/heads/") && currentPath) { + worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); + } else if (line === "") { + currentPath = null; + } + } + } + + const branches = result.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const name = line.replace(/^[*+]\s+/, ""); + return { + name, + current: line.startsWith("* "), + isDefault: name === defaultBranch, + worktreePath: worktreeMap.get(name) ?? null, + }; + }) + .toSorted((a, b) => { + if (a.current !== b.current) return a.current ? -1 : 1; + if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return { branches, isRepo: true }; +} + +export async function createGitWorktree( + input: GitCreateWorktreeInput, +): Promise { + const sanitizedBranch = input.newBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = + input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch); + + // Create a new branch from the base branch in a new worktree + const result = await runGit( + ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], + input.cwd, + ); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git worktree add failed"); + } + + return { + worktree: { + path: worktreePath, + branch: input.newBranch, + }, + }; +} + +export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise { + const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git worktree remove failed"); + } +} + +export async function createGitBranch(input: GitCreateBranchInput): Promise { + const result = await runGit(["branch", input.branch], input.cwd, 10_000); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git branch create failed"); + } +} + +export async function checkoutGitBranch(input: GitCheckoutInput): Promise { + const result = await runGit(["checkout", input.branch], input.cwd, 10_000); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git checkout failed"); + } +} + +export async function initGitRepo(input: GitInitInput): Promise { + const result = await runGit(["init"], input.cwd, 10_000); + + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "git init failed"); + } +} diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index c159bb296c..424cfce16c 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -16,10 +16,7 @@ describe("ProviderManager", () => { method: string; threadId: string; }) => void; - threadLogStreams: Map< - string, - { writableEnded: boolean; destroyed: boolean } - >; + threadLogStreams: Map; }; internals.onCodexEvent({ id: "evt-1", diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index be17babc35..52fb1b6484 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -87,11 +87,7 @@ export class ProviderManager extends EventEmitter { throw new Error(`Unknown provider session: ${input.sessionId}`); } - await this.codex.respondToRequest( - input.sessionId, - input.requestId, - input.decision, - ); + await this.codex.respondToRequest(input.sessionId, input.requestId, input.decision); } stopSession(raw: ProviderStopSessionInput): void { @@ -151,10 +147,7 @@ export class ProviderManager extends EventEmitter { private resolveThreadId(event: ProviderEvent): string | undefined { const fromPayload = this.readThreadIdFromPayload(event.payload); - const threadId = - event.threadId ?? - fromPayload ?? - this.sessionThreadIds.get(event.sessionId); + const threadId = event.threadId ?? fromPayload ?? this.sessionThreadIds.get(event.sessionId); if (threadId) { this.sessionThreadIds.set(event.sessionId, threadId); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ef5cb1b963..770d1ccdb9 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -281,6 +281,32 @@ describe("WebSocket Server", () => { expect(afterRemove.result).toEqual([]); }); + it("supports git methods over websocket", async () => { + const repoCwd = makeTempDir("t3code-ws-git-project-"); + + server = createTestServer({ cwd: "/test" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const beforeInit = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: repoCwd }); + expect(beforeInit.error).toBeUndefined(); + expect(beforeInit.result).toEqual({ branches: [], isRepo: false }); + + const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: repoCwd }); + expect(initResponse.error).toBeUndefined(); + + const afterInit = await sendRequest(ws, WS_METHODS.gitListBranches, { + cwd: repoCwd, + }); + expect(afterInit.error).toBeUndefined(); + expect((afterInit.result as { isRepo: boolean }).isRepo).toBe(true); + }); + it("prunes missing projects on startup", async () => { const stateDir = makeTempDir("t3code-ws-prune-state-"); const existing = makeTempDir("t3code-ws-existing-project-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ae9dc7ca68..353535ae32 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -19,6 +19,14 @@ import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; import { ProjectRegistry } from "./projectRegistry"; import { ProviderManager } from "./providerManager"; +import { + checkoutGitBranch, + createGitBranch, + createGitWorktree, + initGitRepo, + listGitBranches, + removeGitWorktree, +} from "./git"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -304,6 +312,24 @@ export function createServer(options: ServerOptions) { return undefined; } + case WS_METHODS.gitListBranches: + return listGitBranches(request.params as never); + + case WS_METHODS.gitCreateWorktree: + return createGitWorktree(request.params as never); + + case WS_METHODS.gitRemoveWorktree: + return removeGitWorktree(request.params as never); + + case WS_METHODS.gitCreateBranch: + return createGitBranch(request.params as never); + + case WS_METHODS.gitCheckout: + return checkoutGitBranch(request.params as never); + + case WS_METHODS.gitInit: + return initGitRepo(request.params as never); + case WS_METHODS.serverGetConfig: return { cwd }; @@ -343,7 +369,10 @@ export function createServer(options: ServerOptions) { const isServerNotRunningError = (error: unknown): boolean => { if (!(error instanceof Error)) return false; const maybeCode = (error as NodeJS.ErrnoException).code; - return maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running"); + return ( + maybeCode === "ERR_SERVER_NOT_RUNNING" || + error.message.toLowerCase().includes("not running") + ); }; const closeWebSocketServer = new Promise((resolve, reject) => { diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index d6c715e1c1..bf7111184a 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node"], - "lib": ["ES2023"] + "lib": ["ES2023", "esnext.disposable"] }, - "include": ["src", "tsup.config.ts"] + "include": ["src", "tsdown.config.ts"] } diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts new file mode 100644 index 0000000000..1a67bab626 --- /dev/null +++ b/apps/server/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + outDir: "dist", + sourcemap: true, + clean: true, + noExternal: ["@t3tools/contracts", "zod"], + inlineOnly: false, + banner: { + js: "#!/usr/bin/env node\n", + }, +}); diff --git a/apps/server/tsup.config.ts b/apps/server/tsup.config.ts deleted file mode 100644 index 6708d8e27c..0000000000 --- a/apps/server/tsup.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - format: "esm", - outDir: "dist", - sourcemap: true, - clean: true, - noExternal: ["@t3tools/contracts"], - banner: { - js: '#!/usr/bin/env node\n', - }, -}); diff --git a/apps/server/turbo.jsonc b/apps/server/turbo.jsonc new file mode 100644 index 0000000000..34f8874f8e --- /dev/null +++ b/apps/server/turbo.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "start": { + "dependsOn": ["build"], + "cache": false, + "persistent": true, + }, + }, +} diff --git a/apps/web/index.html b/apps/web/index.html index fe848c5287..25aace82a3 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,7 +5,10 @@ - + T3 Code diff --git a/apps/web/package.json b/apps/web/package.json index 57f79caa4f..d995f4819b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@t3tools/contracts": "workspace:*", + "@tanstack/react-query": "^5.90.0", "highlight.js": "^11.11.1", "lucide-react": "^0.563.0", "react": "^19.0.0", @@ -25,10 +26,10 @@ "@tailwindcss/vite": "^4.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^5.1.4", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", - "vite": "^6.0.5" + "vite": "^8.0.0-beta.12" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e12d563ab9..4619f6c933 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import ChatView from "./components/ChatView"; @@ -82,6 +83,8 @@ function AutoProjectBootstrap() { events: [], error: null, createdAt: new Date().toISOString(), + branch: null, + worktreePath: null, }, }); }); @@ -166,10 +169,14 @@ function Layout() { ); } +const queryClient = new QueryClient(); + export default function App() { return ( - - - + + + + + ); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx new file mode 100644 index 0000000000..2379d0b91c --- /dev/null +++ b/apps/web/src/components/BranchToolbar.tsx @@ -0,0 +1,315 @@ +import type { GitBranch } from "@t3tools/contracts"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { readNativeApi } from "../session-logic"; +import { useStore } from "../store"; + +interface BranchToolbarProps { + envMode: "local" | "worktree"; + onEnvModeChange: (mode: "local" | "worktree") => void; + envLocked: boolean; +} + +export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) { + const { state, dispatch } = useStore(); + const api = useMemo(() => readNativeApi(), []); + const queryClient = useQueryClient(); + + const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); + const [isCreatingBranch, setIsCreatingBranch] = useState(false); + const [newBranchName, setNewBranchName] = useState(""); + const branchMenuRef = useRef(null); + + const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); + const activeProject = state.projects.find((project) => project.id === activeThread?.projectId); + const activeThreadId = activeThread?.id; + const activeThreadBranch = activeThread?.branch ?? null; + const activeWorktreePath = activeThread?.worktreePath ?? null; + const branchCwd = activeWorktreePath ?? activeProject?.cwd; + + // ── Queries ─────────────────────────────────────────────────────────── + + const branchesQuery = useQuery({ + queryKey: ["git", "branches", branchCwd], + queryFn: () => api!.git.listBranches({ cwd: branchCwd! }), + enabled: !!api && !!branchCwd, + }); + + const branches = branchesQuery.data?.branches ?? []; + // Default to true while loading — showing "Initialize git" during a fetch is wrong, + // and worktrees are inherently git repos. + const isRepo = branchesQuery.data?.isRepo ?? !branchesQuery.isLoading; + + // ── Mutations ───────────────────────────────────────────────────────── + + const checkoutMutation = useMutation({ + mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), + onError: (error) => + setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."), + }); + + const createBranchMutation = useMutation({ + mutationFn: (branch: string) => + api!.git + .createBranch({ cwd: branchCwd!, branch }) + .then(() => api!.git.checkout({ cwd: branchCwd!, branch })), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }), + onError: (error) => + setThreadError(error instanceof Error ? error.message : "Failed to create branch."), + }); + + const initMutation = useMutation({ + mutationFn: () => api!.git.init({ cwd: branchCwd! }), + onSuccess: () => { + setThreadError(null); + queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }); + }, + onError: (error) => + setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."), + }); + + // ── Effects ─────────────────────────────────────────────────────────── + + // Keep thread branch synced to git current branch for local threads. + const queryBranches = branchesQuery.data?.branches; + useEffect(() => { + if (!activeThreadId || activeWorktreePath) return; + const current = queryBranches?.find((branch) => branch.current); + if (!current) return; + if (current.name === activeThreadBranch) return; + dispatch({ + type: "SET_THREAD_BRANCH", + threadId: activeThreadId, + branch: current.name, + worktreePath: null, + }); + }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]); + + useEffect(() => { + if (!isBranchMenuOpen) return; + const handleClickOutside = (event: MouseEvent) => { + if (!branchMenuRef.current) return; + if (event.target instanceof Node && !branchMenuRef.current.contains(event.target)) { + setIsBranchMenuOpen(false); + } + }; + window.addEventListener("mousedown", handleClickOutside); + return () => { + window.removeEventListener("mousedown", handleClickOutside); + }; + }, [isBranchMenuOpen]); + + // ── Helpers ─────────────────────────────────────────────────────────── + + const setThreadError = (error: string | null) => { + if (!activeThreadId) return; + dispatch({ type: "SET_ERROR", threadId: activeThreadId, error }); + }; + + const setThreadBranch = (branch: string | null, worktreePath: string | null) => { + if (!activeThreadId) return; + // If the effective cwd is about to change, stop the running session so the + // next message creates a new one with the correct cwd. + const sessionId = activeThread?.session?.sessionId; + if (sessionId && worktreePath !== activeWorktreePath) { + void api?.providers.stopSession({ sessionId }).catch(() => undefined); + } + dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); + }; + + const selectBranch = (branch: GitBranch) => { + if (!api || !activeThreadId || !branchCwd) return; + + // For new worktree mode, selecting a branch picks the base branch. + if (envMode === "worktree" && !envLocked && !activeWorktreePath) { + setThreadError(null); + setThreadBranch(branch.name, null); + setIsBranchMenuOpen(false); + return; + } + + // If the branch already lives in a worktree, redirect there instead of + // trying to checkout (which git would reject with "already used by worktree"). + if (branch.worktreePath) { + const isMainWorktree = branch.worktreePath === activeProject?.cwd; + setThreadError(null); + // Main worktree → switch back to local (project cwd, worktreePath=null). + // Secondary worktree → point the thread at that worktree path. + setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); + setIsBranchMenuOpen(false); + return; + } + + checkoutMutation.mutate(branch.name, { + onSuccess: () => { + setThreadError(null); + setThreadBranch(branch.name, activeWorktreePath); + setIsBranchMenuOpen(false); + }, + }); + }; + + const createBranch = () => { + const name = newBranchName.trim(); + if (!api || !activeThreadId || !branchCwd || !name) return; + createBranchMutation.mutate(name, { + onSuccess: () => { + setThreadError(null); + setThreadBranch(name, activeWorktreePath); + setNewBranchName(""); + setIsCreatingBranch(false); + setIsBranchMenuOpen(false); + }, + }); + }; + + if (!activeThread || !activeProject) return null; + + return ( +
+
+ {envLocked || activeWorktreePath ? ( + + {activeWorktreePath ? "Worktree" : "Local"} + + ) : ( + + )} +
+ + {!isRepo ? ( + + ) : ( +
+ + {isBranchMenuOpen && ( +
+

+ Branch +

+
+ {branches.map((branch) => { + const isSelected = branch.name === activeThread.branch; + const hasSecondaryWorktree = + branch.worktreePath && branch.worktreePath !== activeProject.cwd; + return ( + + ); + })} +
+ {envMode === "local" && ( + <> +
+ {isCreatingBranch ? ( + { + event.preventDefault(); + createBranch(); + }} + > + setNewBranchName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setIsCreatingBranch(false); + setNewBranchName(""); + } + }} + // biome-ignore lint/a11y/noAutofocus: branch name input should focus when shown + autoFocus + /> + + + ) : ( + + )} + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c28c7da55..9792d07fff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -33,6 +33,7 @@ import { readNativeApi, } from "../session-logic"; import { useStore } from "../store"; +import BranchToolbar from "./BranchToolbar"; import ChatMarkdown from "./ChatMarkdown"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -91,23 +92,17 @@ function approvalDetail(event: ProviderEvent): string | undefined { return asString(payload?.reason); } -function derivePendingApprovals( - events: ProviderEvent[], -): PendingApprovalCard[] { +function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] { const pending = new Map(); const ordered = [...events].toReversed(); for (const event of ordered) { - if ( - event.method === "session/closed" || - event.method === "session/exited" - ) { + if (event.method === "session/closed" || event.method === "session/exited") { pending.clear(); continue; } - const requestId = - event.requestId ?? asString(asRecord(event.payload)?.requestId); + const requestId = event.requestId ?? asString(asRecord(event.payload)?.requestId); if (!requestId) continue; if ( @@ -142,19 +137,13 @@ export default function ChatView() { const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) - ? (stored as EditorId) - : EDITORS[0].id; + return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; }); - const [selectedEffort, setSelectedEffort] = - useState(DEFAULT_REASONING); + const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); + const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); - const [respondingRequestIds, setRespondingRequestIds] = useState( - [], - ); - const [expandedWorkGroups, setExpandedWorkGroups] = useState< - Record - >({}); + const [respondingRequestIds, setRespondingRequestIds] = useState([]); + const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -175,11 +164,7 @@ export default function ChatView() { [activeThread?.events], ); const latestTurnWorkEntries = useMemo( - () => - deriveWorkLogEntries( - activeThread?.events ?? [], - activeThread?.latestTurnId, - ), + () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId), [activeThread?.events, activeThread?.latestTurnId], ); const pendingApprovals = useMemo( @@ -265,19 +250,20 @@ export default function ChatView() { approvalPolicy: "on-request", sandboxMode: "workspace-write", } as const); + const envLocked = Boolean( + activeThread && + (activeThread.messages.length > 0 || + (activeThread.session !== null && activeThread.session.status !== "closed")), + ); - const handleRuntimeModeChange = async ( - mode: "approval-required" | "full-access", - ) => { + const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; dispatch({ type: "SET_RUNTIME_MODE", mode }); if (!api) return; const sessionIds = state.threads .map((t) => t.session) - .filter( - (s): s is NonNullable => s !== null && s.status !== "closed", - ) + .filter((s): s is NonNullable => s !== null && s.status !== "closed") .map((s) => s.sessionId); if (sessionIds.length === 0) return; @@ -285,9 +271,7 @@ export default function ChatView() { setIsSwitchingRuntimeMode(true); try { await Promise.all( - sessionIds.map((id) => - api.providers.stopSession({ sessionId: id }).catch(() => undefined), - ), + sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)), ); } finally { setIsSwitchingRuntimeMode(false); @@ -309,6 +293,12 @@ export default function ChatView() { setExpandedWorkGroups({}); }, [activeThread?.id]); + const activeWorktreePath = activeThread?.worktreePath; + useEffect(() => { + if (!activeThread?.id) return; + setEnvMode(activeWorktreePath ? "worktree" : "local"); + }, [activeThread?.id, activeWorktreePath]); + // Auto-resize textarea useEffect(() => { const ta = textareaRef.current; @@ -348,10 +338,7 @@ export default function ChatView() { const handleClickOutside = (event: MouseEvent) => { if (!editorMenuRef.current) return; - if ( - event.target instanceof Node && - !editorMenuRef.current.contains(event.target) - ) { + if (event.target instanceof Node && !editorMenuRef.current.contains(event.target)) { setIsEditorMenuOpen(false); } }; @@ -368,23 +355,25 @@ export default function ChatView() { if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { if (api && activeProject) { e.preventDefault(); - void api.shell.openInEditor(activeProject.cwd, lastEditor); + const cwd = activeThread?.worktreePath ?? activeProject.cwd; + void api.shell.openInEditor(cwd, lastEditor); } } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [api, activeProject, lastEditor]); + }, [api, activeProject, activeThread, lastEditor]); const openInEditor = (editorId: EditorId) => { if (!api || !activeProject) return; - void api.shell.openInEditor(activeProject.cwd, editorId); + const cwd = activeThread?.worktreePath ?? activeProject.cwd; + void api.shell.openInEditor(cwd, editorId); setLastEditor(editorId); localStorage.setItem(LAST_EDITOR_KEY, editorId); setIsEditorMenuOpen(false); }; - const ensureSession = async (): Promise => { + const ensureSession = async (cwdOverride?: string): Promise => { if (!api || !activeThread || !activeProject) return null; if (activeThread.session && activeThread.session.status !== "closed") { const sessionThreadId = activeThread.session.threadId ?? null; @@ -406,7 +395,7 @@ export default function ChatView() { try { const session = await api.providers.startSession({ provider: "codex", - cwd: activeProject.cwd || undefined, + cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, model: selectedModel || undefined, resumeThreadId: priorCodexThreadId ?? undefined, approvalPolicy: runtimeSessionConfig.approvalPolicy, @@ -446,6 +435,39 @@ export default function ChatView() { if (!api || !activeThread || isSending || isConnecting) return; const trimmed = prompt.trim(); if (!trimmed) return; + if (!activeProject) return; + + // On first message: lock in branch + create worktree if needed. + let sessionCwd: string | undefined; + if ( + activeThread.messages.length === 0 && + activeThread.branch && + envMode === "worktree" && + !activeThread.worktreePath + ) { + try { + const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; + const result = await api.git.createWorktree({ + cwd: activeProject.cwd, + branch: activeThread.branch, + newBranch, + }); + sessionCwd = result.worktree.path; + dispatch({ + type: "SET_THREAD_BRANCH", + threadId: activeThread.id, + branch: result.worktree.branch, + worktreePath: result.worktree.path, + }); + } catch (err) { + dispatch({ + type: "SET_ERROR", + threadId: activeThread.id, + error: err instanceof Error ? err.message : "Failed to create worktree", + }); + return; + } + } // Auto-title from first message if (activeThread.messages.length === 0) { @@ -471,21 +493,16 @@ export default function ChatView() { const previousMessages = activeThread.messages; setPrompt(""); - const sessionInfo = await ensureSession(); + const sessionInfo = await ensureSession(sessionCwd); if (!sessionInfo) return; setIsSending(true); try { const shouldBootstrap = previousMessages.length > 0 && - (sessionInfo.continuityState === "new" || - sessionInfo.continuityState === "fallback_new"); + (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); const input = shouldBootstrap - ? buildBootstrapInput( - previousMessages, - trimmed, - PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - ).text + ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text : trimmed; await api.providers.sendTurn({ sessionId: sessionInfo.sessionId, @@ -512,10 +529,7 @@ export default function ChatView() { }); }; - const onRespondToApproval = async ( - requestId: string, - decision: ProviderApprovalDecision, - ) => { + const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => { if (!api || !activeThread?.session) return; setRespondingRequestIds((existing) => @@ -531,15 +545,10 @@ export default function ChatView() { dispatch({ type: "SET_ERROR", threadId: activeThread.id, - error: - err instanceof Error - ? err.message - : "Failed to submit approval decision.", + error: err instanceof Error ? err.message : "Failed to submit approval decision.", }); } finally { - setRespondingRequestIds((existing) => - existing.filter((id) => id !== requestId), - ); + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); } }; @@ -577,11 +586,22 @@ export default function ChatView() { return (
{/* Top bar */} -
+
-

- {activeThread.title} -

+

{activeThread.title}

+ {activeProject && ( + + {activeProject.name} + + )} + {activeThread.branch && ( + + {activeThread.branch} + {activeThread.worktreePath ? " (worktree)" : ""} + + )}
{/* Open in editor */} @@ -606,9 +626,7 @@ export default function ChatView() { {editorLabel(editor)} {editor.id === lastEditor && ( - {navigator.platform.includes("Mac") - ? "\u2318O" - : "Ctrl+O"} + {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"} )} @@ -634,7 +652,7 @@ export default function ChatView() { {/* Error banner */} {activeThread.error && ( -
+
{activeThread.error}
)} @@ -642,9 +660,7 @@ export default function ChatView() { {pendingApprovals.length > 0 && (
{pendingApprovals.map((approval) => { - const isResponding = respondingRequestIds.includes( - approval.requestId, - ); + const isResponding = respondingRequestIds.includes(approval.requestId); return (
- void onRespondToApproval(approval.requestId, "accept") - } + onClick={() => void onRespondToApproval(approval.requestId, "accept")} > Approve once @@ -678,12 +692,7 @@ export default function ChatView() { type="button" className="rounded-md border border-sky-300/30 bg-sky-500/[0.15] px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/[0.22] disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} - onClick={() => - void onRespondToApproval( - approval.requestId, - "acceptForSession", - ) - } + onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")} > Always allow this session @@ -691,9 +700,7 @@ export default function ChatView() { type="button" className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground/90 transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} - onClick={() => - void onRespondToApproval(approval.requestId, "decline") - } + onClick={() => void onRespondToApproval(approval.requestId, "decline")} > Decline @@ -701,9 +708,7 @@ export default function ChatView() { type="button" className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50" disabled={isResponding} - onClick={() => - void onRespondToApproval(approval.requestId, "cancel") - } + onClick={() => void onRespondToApproval(approval.requestId, "cancel")} > Cancel turn @@ -718,15 +723,14 @@ export default function ChatView() {
{activeThread.messages.length === 0 && !isWorking ? (
-

Send a message to start the conversation.

+

+ Send a message to start the conversation. +

) : (
{timelineEntries.map((timelineEntry, index) => { - if ( - timelineEntry.kind === "work" && - timelineEntries[index - 1]?.kind === "work" - ) { + if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { return null; } @@ -747,16 +751,13 @@ export default function ChatView() { const groupId = timelineEntry.id; const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = - groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = hasOverflow && !isExpanded ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : groupedEntries; const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every( - (entry) => entry.tone === "tool", - ); + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); const groupLabel = onlyToolEntries ? groupedEntries.length === 1 ? "Tool call" @@ -842,9 +843,7 @@ export default function ChatView() {
- {completionSummary - ? `Response • ${completionSummary}` - : "Response"} + {completionSummary ? `Response • ${completionSummary}` : "Response"}
@@ -853,24 +852,17 @@ export default function ChatView() {

{formatMessageMeta( timelineEntry.message.createdAt, timelineEntry.message.streaming - ? formatElapsed( - timelineEntry.message.createdAt, - nowIso, - ) + ? formatElapsed(timelineEntry.message.createdAt, nowIso) : formatElapsed( timelineEntry.message.createdAt, - assistantCompletionByItemId.get( - timelineEntry.message.id, - ), + assistantCompletionByItemId.get(timelineEntry.message.id), ), )}

@@ -896,7 +888,7 @@ export default function ChatView() {
{/* Input bar */} -
+
{/* Textarea area */} @@ -1026,9 +1018,7 @@ export default function ChatView() { disabled={isSwitchingRuntimeMode} onClick={() => void handleRuntimeModeChange( - state.runtimeMode === "full-access" - ? "approval-required" - : "full-access", + state.runtimeMode === "full-access" ? "approval-required" : "full-access", ) } title={ @@ -1038,13 +1028,7 @@ export default function ChatView() { } > {state.runtimeMode === "full-access" ? ( - - {state.runtimeMode === "full-access" - ? "Full access" - : "Supervised"} - + {state.runtimeMode === "full-access" ? "Full access" : "Supervised"}
@@ -1165,6 +1139,8 @@ export default function ChatView() {
+ +
); } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 6a546c848f..72e11aa4a5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -7,7 +7,9 @@ export default function DiffPanel() { return (