` across the top of `layout.tsx` that `.map`s over `currentSessions()` and renders a button per session that calls `navigateToSession`
+- No styling beyond `flex gap-2 border-b`
+- Sidebar stays (you're just adding, not replacing)
+- Current project only
+- Use case: screenshot for a design review, internal demo
+
+### Level 2 — Proof-of-concept (~1 day)
+- Extract into a real `SessionTabBar` component with proper typing
+- Active-tab highlight, basic hover states
+- Truncate long titles with CSS
+- Wire `⌘W` to close (archive) the active tab
+- Still current-project-only, sidebar still present, no DnD, no overflow handling (tabs just overflow the viewport)
+- Use case: internal dogfooding to see if the idea feels right
+
+### Level 3 — MVP behind a flag (~3 days)
+- Everything from the "quick prototype" alternative above
+- Add horizontal scroll container so overflow doesn't break the page
+- New-session `+` button at the right of the strip
+- Plain rectangular tabs with close `X` on hover
+- Hidden behind `settings.general.sessionTabs` toggle; sidebar still default
+- Use case: opt-in beta for power users, gather feedback before committing
+
+### Level 4 — Feature-complete single-project (~6 days)
+- Proper tab states: working spinner, unseen dot, permission pulse (reuse `SessionItem` indicators)
+- Middle-click close, context menu (rename, archive, close-others)
+- Inline rename on double-click
+- Overflow menu (dropdown of clipped tabs) — no fancy IntersectionObserver, just "hide after N tabs and put rest in menu"
+- Still current-project-only, sidebar still toggleable
+- Use case: stable opt-in; most single-project users would be happy here
+
+### Level 5 — Multi-project, sidebar optional (~9 days)
+- Global tab bar: tabs from all open projects, grouped visually
+- Project-colored stripe on top of each tab
+- Project switcher dropdown on the far-left (replaces need for rail, rail still available)
+- DnD reorder within a project group
+- `⌘1..9` keybinds to jump to tab
+- Sidebar still exists as a setting; default depends on user preference
+- Use case: public release candidate; users who prefer old layout can keep it
+
+### Level 6 — Sidebar removed, full plan baseline (~11 days)
+- Everything in the main plan above: delete sidebar entirely, `layout.v7` persist migration, rip out hover-preview / peek / AIM-triangle logic
+- Proper Chrome-trapezoid tab shape with CSS masks
+- IntersectionObserver-based overflow detection (tabs shrink to 32 px, then overflow)
+- Refactored `layout.tsx` hooks (`use-project-actions`, etc.)
+- i18n across ~24 locales
+- Basic test coverage
+- Use case: shippable v1 — what the plan estimates
+
+### Level 7 — Production-polished (~15 days)
+- Tab animations: open/close slide, drag-reorder ghost, project group color transition
+- Drag a tab **out of** the window to detach it (new window) — uses Electron `BrowserWindow`, Tauri `WebviewWindow`
+- Drag a tab **between** windows to merge — requires IPC protocol for tab handoff
+- "Reopen closed tab" (`⌘⇧T`) with history ring buffer
+- Pinned tabs (icon-only, persist across launches)
+- Full e2e test suite (Playwright) covering 10+ scenarios
+- Use case: flagship feature launch
+
+### Level 8 — Designed-for-the-long-haul (~22 days, adds a designer)
+- Dedicated design pass with Figma prototype reviewed by 3+ users
+- Motion design spec (easing curves, reduced-motion support)
+- Accessibility audit: full keyboard nav, screen-reader labels, focus trapping, WCAG AA contrast on every tab state
+- Settings for tab width, tab shape (trapezoid vs. rectangle), group-by-project toggle, show-icon toggle
+- Analytics instrumentation: tab-switch latency, overflow-menu usage, close-vs-archive ratio
+- Migration UI: one-time onboarding popover explaining the change
+- Use case: a feature you're confident won't need a redesign in 12 months
+
+### Level 9 — Enterprise-grade (~30 days)
+- Tab sync across devices (if the app has cloud-synced settings, which this one tracks via `Persist.global`)
+- Tab grouping by something other than project (custom user-defined groups, Chrome-style)
+- Vertical-tabs mode as an alternate layout (power-user request, common on wide-but-short monitors)
+- Tab search (`⌘⇧A`) with fuzzy match over session titles
+- Full i18n QA with native-speaker review per locale
+- Performance budget: 200+ tabs without jank (virtualized rendering)
+- Telemetry dashboard + a/b test infra to measure impact on retention
+- Use case: a primary surface of a product with millions of users
+
+### Level 10 — Research-grade (~45+ days)
+- User research phase: 10+ interviews, diary studies, preference testing against the current sidebar
+- Multiple design candidates A/B tested with real users before committing
+- Academic-level accessibility: tested with actual screen-reader users, voice-control users, low-vision users
+- Localization partner review for RTL languages (Arabic, Hebrew) including tab-order reversal
+- Performance: 60fps on a 5-year-old laptop with 500 tabs across 20 projects
+- Failure modes: offline behavior, corrupt persist state recovery, migration-failure fallback, out-of-memory graceful degradation
+- Formal rollout plan: 1% → 10% → 50% → 100% with rollback criteria
+- Post-launch six-month observation period with iteration
+- Use case: you're Google Chrome and this is literally your product
+
+### Recommendation
+
+Level 3 or Level 4 first (3–6 days), see if the team and users actually like it, then commit to Level 6 (the full plan, 11 days) if the feedback is positive. Skipping straight to Level 6 without validation is the common trap — you burn 2+ weeks, then discover users want vertical tabs or hate losing the hover-preview.
diff --git a/analysis-and-understanding/ui-testing-guide.md b/analysis-and-understanding/ui-testing-guide.md
new file mode 100644
index 000000000000..69aa42377531
--- /dev/null
+++ b/analysis-and-understanding/ui-testing-guide.md
@@ -0,0 +1,300 @@
+# UI Testing Guide (OpenCode)
+
+How to test UI in this repo — what each tier actually does, where tests live, and how to run them. The short version: **almost everything here is headless**, and the one tool that produces screenshots is essentially unused today.
+
+---
+
+## The Three Tiers
+
+| Tier | Tool | File pattern | Runs in | Sees pixels? | Count in repo |
+|---|---|---|---|---|---|
+| Unit / logic | `bun test` | `*.test.ts` (many in `test/` dirs) | Bun runtime (no DOM) | ❌ No | ~173 files |
+| Component / DOM | `bun test --preload ./happydom.ts` | `*.test.ts` co-located with source | Bun + **happy-dom** (simulated DOM) | ❌ No (DOM exists in memory, never rendered) | ~52 files (all `packages/app`) |
+| E2E | `playwright test` | `e2e/*.spec.ts` | Real **Chromium** browser | ✅ Yes — screenshots, videos, traces on failure | **1 file** (a `test.fixme()` stub) |
+
+### Bun tests are headless
+
+`bun:test` is Bun's built-in Jest/Vitest-compatible runner. Code runs directly in the Bun runtime — no browser, no Electron, no Tauri webview. Output is plain pass/fail text. No `page`, no `click()`, no screenshots, ever.
+
+```ts
+import { describe, expect, test } from "bun:test"
+
+describe("foo", () => {
+ test("bar", () => {
+ expect(1 + 1).toBe(2)
+ })
+})
+```
+
+### happy-dom tests are also headless (even though they test components)
+
+The 52 files in `packages/app` preload `happydom.ts`, which registers `document`, `window`, `Element`, etc. as globals into the Bun process. Solid components mount into a detached `` and assertions check `textContent`, attributes, dispatched events. There is **no layout, no paint, no CSS rendering** — happy-dom doesn't run a compositor. You can't screenshot what isn't rendered.
+
+```ts
+import { render } from "solid-js/web"
+import { test, expect } from "bun:test"
+
+test("renders title", () => {
+ const div = document.createElement("div")
+ render(() => , div)
+ expect(div.textContent).toContain("hi")
+})
+```
+
+### Playwright E2E is the only source of visuals
+
+And it's currently empty. Screenshots/videos exist in the config but only fire on failure, and there's nothing substantive to fail:
+
+```ts
+// packages/app/playwright.config.ts
+use: {
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ video: "retain-on-failure",
+}
+```
+
+So nothing visual is being generated anywhere in the project today.
+
+---
+
+## Running Bun Tests
+
+The default branch is `dev`. Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from each package directory.
+
+### `packages/opencode` — unit + integration (157 files)
+
+```bash
+cd packages/opencode
+bun test # everything
+bun test test/session # a subtree
+bun test test/session/session.test.ts # one file
+bun test --watch # re-run on save
+```
+
+Mirrors `src/` under `test/`: `test/session/`, `test/util/`, `test/tool/`, `test/cli/tui/`, `test/plugin/`, `test/file/`, `test/server/`, `test/cli/`, `test/project/`, `test/mcp/`, `test/effect/`, `test/config/`, `test/provider/` (+ `copilot/`), `test/lsp/`, `test/storage/`, `test/pty/`, `test/bus/`, `test/skill/`, `test/permission/`, `test/control-plane/`, plus a few at `test/` root.
+
+### `packages/app` — components + utilities (52 files)
+
+These need happy-dom preloaded:
+
+```bash
+cd packages/app
+bun run test:unit # = bun test --preload ./happydom.ts ./src
+bun run test:unit:watch # same, --watch
+bun run test # alias for test:unit
+```
+
+Organized by role: `src/utils/` (11), `src/context/` (11 + `context/global-sync/` 6), `src/pages/session/` (6), `src/components/prompt-input/` (6), `src/components/` (3).
+
+### Other packages
+
+```bash
+cd packages/ui && bun test # 5 files
+cd packages/shared && bun test # 2 files
+cd packages/console && bun test # 3 files
+cd packages/enterprise && bun test # 2 files
+cd packages/desktop-electron && bun test # 2 files (shell-env + html)
+```
+
+### Testing conventions (from repo `AGENTS.md`)
+
+- **Avoid mocks** as much as possible
+- **Test actual implementation**; don't duplicate logic into tests
+- Never `cd` then run — use the package directory directly
+
+---
+
+## Running Playwright (the only visual option)
+
+### TL;DR
+
+From `packages/app/`:
+
+```bash
+PLAYWRIGHT_PORT=3030 bun run test:e2e -- --headed
+```
+
+Result today: `1 skipped`. The only spec is `e2e/todo.spec.ts`, a `test.fixme()` placeholder. Getting it to skip cleanly means the pipeline (Bun → Vite webServer → Playwright → Chromium) is wired end-to-end.
+
+### First-time setup
+
+OpenCode pins Bun in its root `package.json` (`"packageManager": "bun@1.3.11"`) and uses Bun-only APIs like `Bun.file()`. npm/pnpm won't work.
+
+```bash
+# 1. Install Bun if missing
+brew install oven-sh/bun/bun # or: curl -fsSL https://bun.sh/install | bash
+
+# 2. Install workspace deps (from repo root)
+bun install
+
+# 3. Install the Chromium browser binary (from packages/app)
+bunx playwright install chromium # add --with-deps on Linux
+```
+
+### The port-3000 gotcha
+
+`packages/app/playwright.config.ts` defaults the Vite `webServer` to port **3000**. If something else is on that port (Docker Desktop commonly holds 3000–3002), Vite auto-shifts and Playwright waits 120 s on the wrong port, then fails with:
+
+```
+Error: Timed out waiting 120000ms from config.webServer.
+```
+
+Detect:
+
+```bash
+lsof -nP -iTCP:3000 -iTCP:3001 -iTCP:3002 -sTCP:LISTEN
+```
+
+Avoid — set an explicit free port. The same env var feeds both Vite and Playwright:
+
+```bash
+PLAYWRIGHT_PORT=3030 bun run test:e2e -- --headed
+```
+
+### Commands
+
+All from `packages/app/`:
+
+```bash
+# Headed — watch Chromium
+PLAYWRIGHT_PORT=3030 bun run test:e2e -- --headed
+
+# Headless (fast, matches CI)
+PLAYWRIGHT_PORT=3030 bun run test:e2e
+
+# Interactive UI mode — best for writing/debugging specs
+PLAYWRIGHT_PORT=3030 bun run test:e2e:ui
+
+# Single spec
+PLAYWRIGHT_PORT=3030 bunx playwright test e2e/todo.spec.ts --headed
+
+# Codegen — record a test by clicking
+PLAYWRIGHT_PORT=3030 bunx playwright codegen http://127.0.0.1:3030
+
+# Open the last HTML report
+bun run test:e2e:report
+```
+
+### What the config provides
+
+From `packages/app/playwright.config.ts`:
+
+- `testDir: "./e2e"`, `outputDir: "./e2e/test-results"`
+- 60 s test timeout, 10 s `expect` timeout
+- CI: 2 retries; local: 0
+- Single project: `chromium` with `devices["Desktop Chrome"]`
+- `webServer.command` = `bun run dev -- --host 0.0.0.0 --port ${PLAYWRIGHT_PORT ?? 3000}` with 120 s boot window and `reuseExistingServer: !CI`
+- Env passed into Vite: `VITE_OPENCODE_SERVER_HOST` (default `127.0.0.1`), `VITE_OPENCODE_SERVER_PORT` (default `4096`)
+- Reporters: HTML (`e2e/playwright-report`) + line; adds JUnit when `PLAYWRIGHT_JUNIT_OUTPUT` is set
+- Failure artifacts: trace on first retry, screenshot only on failure, video retained on failure
+
+### Env vars worth knowing
+
+| Variable | Purpose |
+|---|---|
+| `PLAYWRIGHT_PORT` | Port Vite + Playwright share (workaround for 3000 conflict) |
+| `PLAYWRIGHT_BASE_URL` | Full URL override (e.g. remote deploy) |
+| `PLAYWRIGHT_SERVER_HOST` | Forwarded to Vite as `VITE_OPENCODE_SERVER_HOST` |
+| `PLAYWRIGHT_SERVER_PORT` | Forwarded to Vite as `VITE_OPENCODE_SERVER_PORT` |
+| `PLAYWRIGHT_WORKERS` | Parallel workers (5 in CI by default, auto local) |
+| `PLAYWRIGHT_FULLY_PARALLEL` | `"1"` to parallelize within files |
+| `PLAYWRIGHT_JUNIT_OUTPUT` | Path to write JUnit XML for CI |
+
+### Running against a real backend
+
+The config only starts Vite — not `opencode serve`. If your spec hits API routes, start the backend in a second terminal first:
+
+```bash
+# Terminal 1 — backend (from packages/opencode)
+bun run --conditions=browser ./src/index.ts serve --port 4096
+
+# Terminal 2 — tests (from packages/app)
+PLAYWRIGHT_PORT=3030 \
+ PLAYWRIGHT_SERVER_HOST=127.0.0.1 \
+ PLAYWRIGHT_SERVER_PORT=4096 \
+ bun run test:e2e -- --headed
+```
+
+Defaults already point at `127.0.0.1:4096`, so the `PLAYWRIGHT_SERVER_*` vars are optional if your backend matches.
+
+**Per `packages/app/AGENTS.md`: never restart the app or server process during a debugging session** — start it once and leave it running.
+
+### Current state of the E2E suite
+
+`packages/app/e2e/todo.spec.ts` is the only spec:
+
+```ts
+import { test } from "@playwright/test"
+
+test(
+ "test something cool",
+ { annotation: { type: "todo" } },
+ async () => {
+ test.fixme()
+ },
+)
+```
+
+`test.fixme()` marks it as a known-broken placeholder, so it always skips. **There is no substantive E2E coverage today.** Getting Playwright to run successfully currently just proves the harness works; writing real specs is the next step.
+
+---
+
+## First-Run Reproduction Log
+
+```
+$ which bun
+bun not found
+
+$ brew install oven-sh/bun/bun
+...
+🍺 /opt/homebrew/Cellar/bun/1.3.12: 8 files, 61.5MB, built in 2 seconds
+
+$ cd opencode && bun install
+...
+4764 packages installed [32.03s]
+
+$ cd packages/app && bunx playwright install chromium
+(installs Chromium binary)
+
+$ bun run test:e2e -- --headed
+$ playwright test --headed
+[WebServer] $ vite --host "0.0.0.0" --port "3000"
+Error: Timed out waiting 120000ms from config.webServer.
+
+$ lsof -nP -iTCP:3000 -iTCP:3001 -iTCP:3002 -sTCP:LISTEN
+COMMAND PID NAME
+com.docke 14869 *:3000
+com.docke 14869 *:3001
+com.docke 14869 *:3002
+
+$ PLAYWRIGHT_PORT=3030 bun run test:e2e -- --headed
+$ playwright test --headed
+[WebServer] $ vite --host "0.0.0.0" --port "3030"
+Running 1 test using 1 worker
+[1/1] [chromium] › e2e/todo.spec.ts:3:1 › test something cool
+ 1 skipped
+```
+
+---
+
+## Which Tier Should I Use?
+
+- **Assert logic, data flow, types, service composition** → `bun test` (fast, hundreds of ms, no window)
+- **Assert that a component renders the right text/attrs/events** → `bun test` + happy-dom (still no window, simulated DOM only)
+- **See the app, take screenshots, check CSS rendering, click real elements, assert navigation** → Playwright (basically unwritten here; you'll be adding the first real specs)
+
+Desktop shells (`packages/desktop`, `packages/desktop-electron`) have their own tiny unit coverage (Rust `#[test]` and Bun `*.test.ts`). Neither has Playwright, Spectron, or `tauri driver` — there is no automated test that launches the packaged native app today. See `opencode-desktop-testing.md` for that deeper story.
+
+---
+
+## Troubleshooting
+
+- **`Timed out waiting 120000ms from config.webServer`** → port conflict. Check `lsof`, set `PLAYWRIGHT_PORT`.
+- **`browserType.launch: Executable doesn't exist at .../chromium...`** → you skipped `bunx playwright install chromium`.
+- **`command not found: bun`** → `brew install oven-sh/bun/bun`.
+- **`1 skipped` every run** → expected. The only spec is `test.fixme()`. Write a real one.
+- **HMR / "Page closed" errors headed** → spec finishing before the page stabilizes; add `await expect(page).toHaveURL(...)` or `await page.waitForLoadState("networkidle")`.
+- **CI-only failures** → CI enables `retries: 2` and (optionally) `fullyParallel`. Reproduce locally with `CI=1 PLAYWRIGHT_FULLY_PARALLEL=1 bun run test:e2e`.
+- **happy-dom test `ReferenceError: document is not defined`** → run through `packages/app`'s scripts (`bun run test:unit`), not raw `bun test` — those scripts pass `--preload ./happydom.ts`.
+- **"do-not-run-tests-from-root" error** → `bun test` must be invoked from a package directory, not repo root.
diff --git a/packages/desktop/scripts/predev.patched.ts b/packages/desktop/scripts/predev.patched.ts
new file mode 100644
index 000000000000..40f8cd6531a7
--- /dev/null
+++ b/packages/desktop/scripts/predev.patched.ts
@@ -0,0 +1,37 @@
+import { $ } from "bun"
+
+import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
+
+const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
+
+const sidecarConfig = getCurrentSidecar(RUST_TARGET)
+
+const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
+
+// LOCAL DEV PATCH: skip rebuild if binary already exists and is ad-hoc signed & runnable.
+// This avoids the macOS Gatekeeper smoke-test failure where `bun build --compile` produces
+// a binary whose embedded signature is rejected (`codesign -v` → "invalid or unsupported
+// format for signature"), causing `opencode --version` to be SIGKILLed (exit 137).
+import { existsSync } from "node:fs"
+// binaryPath is relative to packages/desktop/ (Tauri's CWD when it invokes predev).
+// From predev.ts (which lives in packages/desktop/scripts/) that means ../../opencode/...
+const resolvedBinaryPath = `${import.meta.dirname}/../${binaryPath}`
+if (!existsSync(resolvedBinaryPath)) {
+ await (sidecarConfig.ocBinary.includes("-baseline")
+ ? $`cd ../opencode && bun run build --single --baseline`
+ : $`cd ../opencode && bun run build --single`)
+ await $`codesign --remove-signature ${resolvedBinaryPath}`.nothrow()
+ await $`codesign --force --sign - ${resolvedBinaryPath}`
+} else {
+ console.log(`[predev] Skipping sidecar rebuild, reusing ${resolvedBinaryPath}`)
+ // Make sure it is signed even if it was built by a previous run
+ await $`codesign --remove-signature ${resolvedBinaryPath}`.nothrow()
+ await $`codesign --force --sign - ${resolvedBinaryPath}`
+}
+
+await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
+// Re-sign the copy Tauri will spawn (codesign strips on copy on modern macOS).
+const sidecarDest = `${import.meta.dirname}/../src-tauri/sidecars/opencode-cli-${RUST_TARGET}`
+await $`codesign --remove-signature ${sidecarDest}`.nothrow()
+await $`codesign --force --sign - ${sidecarDest}`
+console.log(`[predev] Signed sidecar at ${sidecarDest}`)