From 240e1d918a32b9150d9f6e521a20a67fd060a401 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 26 May 2026 16:41:11 -0400 Subject: [PATCH 1/3] Desktop UI cleanup and Linear issue resolve flows. Refresh Work, Lanes, chat, and shell surfaces with shared lane/VCS chrome, add Linear quick-view resolve actions with confirmation modals and Work navigation, and land related dev/runtime helpers on the branch. Co-authored-by: Cursor --- README.md | 10 + apps/desktop/README.md | 158 ++++ apps/desktop/package.json | 2 + .../agent-skills/ade-app-control/SKILL.md | 22 + .../scripts/browser-runtime-bridge.mjs | 271 ++++++ apps/desktop/scripts/dev-vite-live.mjs | 89 ++ .../src/main/services/cto/linearAuth.test.ts | 2 +- .../src/main/services/cto/linearClient.ts | 23 +- .../src/main/services/lanes/laneService.ts | 27 +- apps/desktop/src/renderer/browserMock.ts | 265 +++++- .../src/renderer/browserRuntimeBridge.ts | 165 ++++ .../components/app/CommandPalette.tsx | 8 +- .../components/app/LinearIssueBrowser.tsx | 491 +++++++---- .../app/LinearIssueResolveModals.tsx | 327 ++++++++ .../components/app/LinearQuickViewButton.tsx | 326 ++++++-- .../renderer/components/app/TabNav.test.tsx | 17 +- .../src/renderer/components/app/TabNav.tsx | 16 +- .../renderer/components/app/TopBar.test.tsx | 17 +- .../src/renderer/components/app/TopBar.tsx | 521 ++++++++---- .../chat/AgentChatComposer.test.tsx | 19 +- .../components/chat/AgentChatComposer.tsx | 131 +-- .../components/chat/AgentChatPane.tsx | 697 ++++++++-------- .../chat/ChatActionsDrawerPanel.tsx | 74 ++ .../chat/ChatBuiltInBrowserPanel.tsx | 12 - .../components/chat/ChatComposerShell.tsx | 45 +- .../components/chat/ChatGitToolbar.test.tsx | 10 - .../components/chat/ChatGitToolbar.tsx | 238 +----- .../components/chat/ChatIosSimulatorPanel.tsx | 3 +- .../components/chat/ChatSurfaceShell.tsx | 15 +- .../components/chat/ChatTerminalDrawer.tsx | 7 +- .../components/files/FilesExplorer.tsx | 53 ++ .../components/files/FilesPage.test.tsx | 39 + .../renderer/components/files/FilesPage.tsx | 110 ++- .../components/history/HistoryPage.tsx | 5 +- .../components/lanes/BranchPickerView.tsx | 5 +- .../components/lanes/LaneGitActionsPane.tsx | 5 + .../renderer/components/lanes/LanesPage.tsx | 7 +- .../components/lanes/ManageLaneDialog.tsx | 13 +- .../components/lanes/laneColorPalette.ts | 75 +- .../components/lanes/linearProjectIcon.tsx | 154 ++++ .../components/onboarding/HelpMenu.tsx | 11 +- .../renderer/components/prs/CreatePrModal.tsx | 11 +- .../components/prs/detail/PrDetailPane.tsx | 8 +- .../prs/shared/PrLaneCleanupBanner.tsx | 5 +- .../prs/shared/PrRequestAiReviewDialog.tsx | 5 +- .../renderer/components/review/ReviewPage.tsx | 8 +- .../settings/ChatAppearancePreview.tsx | 4 +- .../shared/ModelPicker/ModelPicker.tsx | 12 +- .../ModelPicker/ReasoningEffortPicker.tsx | 2 +- .../components/terminals/LaneChip.test.tsx | 46 + .../components/terminals/LaneChip.tsx | 146 +++- .../components/terminals/LaneCombobox.tsx | 150 ++-- .../OrchestratorComposerEntry.test.tsx | 132 +-- .../components/terminals/SessionCard.tsx | 60 +- .../terminals/SessionListPane.test.tsx | 12 +- .../components/terminals/SessionListPane.tsx | 136 +-- .../components/terminals/TerminalsPage.tsx | 111 +-- .../components/terminals/WorkSidebar.tsx | 123 ++- .../components/terminals/WorkStartSurface.tsx | 6 + .../components/terminals/WorkViewArea.tsx | 581 +++++++------ .../components/terminals/useWorkSessions.ts | 12 +- .../renderer/components/ui/FloatingPane.tsx | 12 +- .../src/renderer/components/ui/GlowMenu.tsx | 197 +++++ .../components/ui/LaneBranchInline.tsx | 89 ++ .../components/ui/PaneTilingLayout.tsx | 2 + .../src/renderer/components/ui/vcsIcons.tsx | 28 + .../components/usage/HeaderUsageControl.tsx | 101 ++- apps/desktop/src/renderer/index.css | 785 ++++++++++++++++-- .../src/renderer/lib/laneNavigation.ts | 7 + .../renderer/lib/linearIssueWorkNavigation.ts | 39 + apps/desktop/src/shared/adeCliGuidance.ts | 1 + apps/desktop/src/shared/laneColorPalette.ts | 67 ++ apps/desktop/src/shared/types/cto.ts | 2 + apps/desktop/vite.config.ts | 7 + scripts/dev-shared.mjs | 4 +- 75 files changed, 5250 insertions(+), 2146 deletions(-) create mode 100644 apps/desktop/README.md create mode 100644 apps/desktop/scripts/browser-runtime-bridge.mjs create mode 100644 apps/desktop/scripts/dev-vite-live.mjs create mode 100644 apps/desktop/src/renderer/browserRuntimeBridge.ts create mode 100644 apps/desktop/src/renderer/components/app/LinearIssueResolveModals.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/linearProjectIcon.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/LaneChip.test.tsx create mode 100644 apps/desktop/src/renderer/components/ui/GlowMenu.tsx create mode 100644 apps/desktop/src/renderer/components/ui/LaneBranchInline.tsx create mode 100644 apps/desktop/src/renderer/components/ui/vcsIcons.tsx create mode 100644 apps/desktop/src/renderer/lib/laneNavigation.ts create mode 100644 apps/desktop/src/renderer/lib/linearIssueWorkNavigation.ts create mode 100644 apps/desktop/src/shared/laneColorPalette.ts diff --git a/README.md b/README.md index 64c0faee6..94518adb0 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,16 @@ npm run dev:stop # stop the dev runtime npm stop dev # same as dev:stop ``` +Browser preview of the desktop renderer (UI work without Electron): + +```bash +cd apps/desktop +npm run dev:vite # mock-only: synthetic window.ade, fast shell +ADE_PROJECT_ROOT=/path/to/project npm run dev:vite:live # mock + live runtime bridge (Linear, sync, lanes) +``` + +`dev:vite:live` starts the ADE dev runtime, a localhost HTTP bridge to the runtime socket, and Vite with a proxy so the browser can call real backend methods on top of the mock. Set `ADE_PROJECT_ROOT` to your primary project checkout (where `.ade/` and secrets live), especially when working from a lane worktree. Full details: [apps/desktop/README.md](apps/desktop/README.md). + The dev commands intentionally use a temp socket and a separate Electron profile so they do not collide with the installed ADE app: ```text diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 000000000..881dacfb1 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,158 @@ +# ADE Desktop + +Electron client for ADE. The renderer is also runnable in a regular browser for fast UI iteration. + +## Surfaces + +| Surface | Command | `window.ade` source | Backend | +|--------|---------|---------------------|---------| +| **Desktop dev** | `npm run dev` (repo root) | Electron preload → main IPC → runtime socket | Full | +| **Browser preview (mock)** | `npm run dev:vite` | `browserMock.ts` only | Synthetic demo data | +| **Browser preview (live)** | `npm run dev:vite:live` | Mock + runtime bridge patches | Partial live (see below) | + +`apps/web` is the public marketing site. This document covers the **desktop renderer in a browser** (`localhost:5173`), not the marketing app. + +## How the browser preview works + +Opening `http://localhost:5173` without Electron loads the same React renderer as the desktop app, but there is no preload bridge. On startup: + +1. **`browserMock.ts`** (imported first in `main.tsx`) installs a full `window.ade` stub so the UI can render without crashing. It returns built-in demo data for PRs, lanes, sessions, git, and so on. +2. **`attachBrowserRuntimeBridge()`** (called at the end of the mock install) probes `GET /ade-dev-rpc/health`. If the browser runtime bridge is running, it **patches** selected methods on top of the mock and dispatches `ade:runtime-bridge-ready`. + +```text +Browser tab + └─ window.ade (mock baseline) + └─ patched methods → fetch /ade-dev-rpc/* + └─ Vite proxy + └─ browser-runtime-bridge.mjs (127.0.0.1:18765) + └─ JSON-RPC → /tmp/ade-runtime-dev.sock + └─ ade serve (same daemon as desktop dev) +``` + +The mock stays the fallback for everything the bridge does not override. UI work that only reads mock data still works with `dev:vite` alone. + +## Launch + +### Mock-only (fast UI shell) + +From `apps/desktop`: + +```bash +npm run dev:vite +``` + +Optional: export SQLite snapshot so lanes, PRs, sessions, and run-tab config mirror a real project: + +```bash +ADE_PROJECT_ROOT=/path/to/your/project npm run export:browser-mock-ade +npm run dev:vite +``` + +The export runs automatically (best-effort) before `dev:vite` via `predev:vite`. Output: + +`src/renderer/browser-mock-ade-snapshot.generated.json` + +That file is gitignored. It seeds **read-only** mock data from `.ade/ade.db` at export time. It does **not** include secrets (Linear tokens, API keys). + +### Live bridge (real Linear, sync, lanes) + +From `apps/desktop`: + +```bash +ADE_PROJECT_ROOT=/path/to/your/project npm run dev:vite:live +``` + +This script: + +1. Builds/refreshes the ADE CLI runtime if needed +2. Ensures the dev runtime is listening on `/tmp/ade-runtime-dev.sock` (override with `ADE_DEV_RUNTIME_SOCKET_PATH`) +3. Starts `browser-runtime-bridge.mjs` on `127.0.0.1:18765` (`ADE_BROWSER_BRIDGE_PORT` to override) +4. Starts Vite on port 5173 with a proxy from `/ade-dev-rpc` → the bridge + +Open `http://localhost:5173` (or `http://127.0.0.1:5173`). + +**Lane worktrees:** if you run from `.ade/worktrees/`, set `ADE_PROJECT_ROOT` to the primary project checkout (where `.ade/ade.db` and secrets live), not the worktree path. Same rule as `npm run dev`. + +Skip runtime rebuild when the CLI is already fresh: + +```bash +ADE_PROJECT_ROOT=/path/to/your/project npm run dev:vite:live -- --skip-runtime-build +``` + +Bridge only (Vite already running): + +```bash +ADE_PROJECT_ROOT=/path/to/your/project npm run dev:browser-bridge +``` + +Verify the bridge: + +```bash +curl -s http://127.0.0.1:18765/health | jq . +``` + +## What the live bridge covers today + +When the bridge attaches, these `window.ade` methods call the real runtime instead of the mock: + +| Area | Methods | +|------|---------| +| **Project** | `app.getProject`, `app.getWindowSession` — real `projectRoot` / `projectId` from `projects.add` | +| **Linear** | `cto.getLinearConnectionStatus`, `getLinearQuickView`, `getLinearIssuePickerData`, `searchLinearIssues`, `getLinearProjects`, `setLinearToken`, `clearLinearToken` | +| **Sync / mobile** | `sync.getStatus`, `refreshDiscovery`, `listDevices`, `updateLocalDevice`, `connectToBrain`, `disconnectFromBrain`, `forgetDevice`, `getTransferReadiness`, `transferBrainToLocal`, `getPin`, `setPin`, `generatePin`, `clearPin` | +| **Lanes** | `lanes.create`, `lanes.list` (e.g. Linear quick view → create lane) | + +Linear must already be connected in that project (Settings → Linear, or token in encrypted store under `.ade/secrets`). The bridge uses the same credentials as desktop dev. + +## Still mock-only in the browser + +Even with `dev:vite:live`, these stay on the mock until wired to the bridge or another backend: + +- Terminals / PTY / live chat sessions +- PR list/detail/actions, git read/write, files on disk +- Remote runtime connection UI (Electron IPC) +- Computer use, App Control, iOS simulator, Mac VM +- Agent chat send/receive, orchestration runs +- Most settings persistence beyond Linear token via bridge + +For full product behavior, use **`npm run dev`** (Electron + preload). + +## Seeding mock data + +Three layers, from lightest to richest: + +1. **Built-in demo** — no setup; synthetic lanes, PRs, sessions in `browserMock.ts`. +2. **Snapshot export** — `npm run export:browser-mock-ade` with `ADE_PROJECT_ROOT` set; replaces demo rows with data from `.ade/ade.db` and optional disk walks for the Files tab. +3. **Live bridge** — real runtime for Linear, sync, and lane create/list; mock still backs everything else unless you also export a snapshot for read-only parity. + +Re-export the snapshot after local DB changes you want reflected in mock-only mode: + +```bash +ADE_PROJECT_ROOT=/path/to/your/project npm run export:browser-mock-ade +``` + +## Environment variables + +| Variable | Purpose | +|----------|---------| +| `ADE_PROJECT_ROOT` | Project opened by the bridge and snapshot export (primary checkout, not lane worktree) | +| `ADE_DEV_RUNTIME_SOCKET_PATH` | Dev runtime socket (default `/tmp/ade-runtime-dev.sock`) | +| `ADE_BROWSER_BRIDGE_PORT` | Bridge HTTP port (default `18765`) | + +## Related files + +| File | Role | +|------|------| +| `src/renderer/browserMock.ts` | Full `window.ade` stub for browser | +| `src/renderer/browserRuntimeBridge.ts` | Patches live methods when bridge is up | +| `scripts/browser-runtime-bridge.mjs` | HTTP → runtime JSON-RPC | +| `scripts/dev-vite-live.mjs` | Orchestrates runtime + bridge + Vite | +| `scripts/export-browser-mock-ade-snapshot.mjs` | SQLite → mock snapshot JSON | +| `vite.config.ts` | Proxies `/ade-dev-rpc` to the bridge | + +## Validation + +```bash +npm run typecheck +npm run test:unit -- src/renderer/components/app/TopBar.test.tsx +``` diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3b03ec75d..885d7c764 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,6 +13,8 @@ "dev:clean": "node ./scripts/clear-vite-cache.cjs && node ./scripts/normalize-runtime-binaries.cjs && node ./scripts/ensure-electron.cjs && node ./scripts/dev.cjs --force-vite", "predev:vite": "node ./scripts/export-browser-mock-ade-snapshot.mjs --optional", "dev:vite": "vite --port 5173 --strictPort", + "dev:vite:live": "node ./scripts/dev-vite-live.mjs", + "dev:browser-bridge": "node ./scripts/browser-runtime-bridge.mjs", "export:browser-mock-ade": "node ./scripts/export-browser-mock-ade-snapshot.mjs", "build": "tsup && vite build", "dist:win": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never && npm run validate:win:release", diff --git a/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md b/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md index 6267ae912..c2fb3c595 100644 --- a/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-app-control/SKILL.md @@ -49,3 +49,25 @@ ade --socket app-control terminal signal --signal SIGINT Only fall back to `ade --socket terminal list --text` and `ade --socket terminal read ...` when no App Control terminal is active. +## Launching ADE itself from inside ADE + +If you are an agent running inside one ADE instance (e.g. ADE Beta or stable) and you need to launch the ADE dev desktop app under App Control, the dev launcher is already isolated and safe to run: + +```bash +ade --socket app-control launch --command "npm run dev" --text +``` + +`npm run dev` uses its own runtime socket (`/tmp/ade-runtime-dev.sock`) and a separate Electron profile (`ade-desktop-dev`), so it will not collide with the runtime/socket that is hosting you. Confirm with `ade runtime status --text` before launching — that tells you which socket the CLI is currently attached to. + +### Survive Electron restarts + +`npm run dev` watches `apps/desktop/src/main/**` and restarts Electron whenever the main bundle rebuilds. After a restart, the App Control drawer UI in the parent ADE window can show stale `Waiting for CDP on 127.0.0.1:` even though the new renderer is already exposed on the same port. From the CLI you can confirm and re-bind: + +```bash +ade --socket app-control targets --text # find the new page target id +ade --socket app-control attach-target --target --text +ade --socket app-control snapshot --text # forces the drawer to repaint +``` + +If `targets` shows a `/devtools/page/` entry with the dev URL (`http://localhost:5173/...`), CDP is healthy — the drawer banner is just lagging until the next snapshot. + diff --git a/apps/desktop/scripts/browser-runtime-bridge.mjs b/apps/desktop/scripts/browser-runtime-bridge.mjs new file mode 100644 index 000000000..853a66733 --- /dev/null +++ b/apps/desktop/scripts/browser-runtime-bridge.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env node + +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + ensureRuntime, + jsonRpcRequestSequence, + resolveDevAppVersion, + resolveDevSocketPath, + resolveProjectRoot, +} from "../../../scripts/dev-shared.mjs"; + +const bridgePath = fileURLToPath(import.meta.url); +const desktopRoot = path.resolve(path.dirname(bridgePath), ".."); +const defaultPort = 18765; + +function bridgeInitializeParams() { + return { + protocolVersion: "2025-06-18", + clientInfo: { name: "ade-browser-bridge", version: resolveDevAppVersion() }, + identity: { + role: "external", + callerId: `ade-browser-bridge:${process.pid}`, + computerUsePolicy: { + mode: "auto", + allowLocalFallback: false, + retainArtifacts: true, + }, + }, + }; +} + +function readRuntimeInfo(value) { + const runtimeInfo = + value && typeof value === "object" && !Array.isArray(value) + ? value.runtimeInfo + : null; + if (!runtimeInfo || typeof runtimeInfo !== "object" || Array.isArray(runtimeInfo)) { + return { version: null }; + } + const version = typeof runtimeInfo.version === "string" && runtimeInfo.version.trim() + ? runtimeInfo.version.trim() + : null; + return { version }; +} + +function readProjectId(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = /** @type {Record} */ (value); + const projectId = typeof record.projectId === "string" ? record.projectId.trim() : ""; + return projectId || null; +} + +function unwrapActionResult(value, request) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = /** @type {Record} */ (value); + if (record.ok === false) { + const error = record.error && typeof record.error === "object" && !Array.isArray(record.error) + ? /** @type {Record} */ (record.error) + : {}; + throw new Error(typeof error.message === "string" ? error.message : "ADE runtime action failed."); + } + return record.result; + } + return value; +} + +async function readJsonBody(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString("utf8").trim(); + if (!raw) return {}; + return JSON.parse(raw); +} + +function sendJson(res, statusCode, payload) { + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(JSON.stringify(payload)); +} + +function projectNameFromRoot(projectRoot) { + return path.basename(projectRoot) || "ADE project"; +} + +async function rpcCall(socketPath, method, params, timeoutMs = 15_000) { + return jsonRpcRequestSequence( + socketPath, + [ + { method: "ade/initialize", params: bridgeInitializeParams() }, + { method, params }, + ], + { timeoutMs }, + ); +} + +async function createBridgeState() { + const projectRoot = resolveProjectRoot(); + const socketPath = resolveDevSocketPath(); + await ensureRuntime(socketPath, projectRoot); + + const runtimeVersion = readRuntimeInfo( + await jsonRpcRequestSequence( + socketPath, + [{ method: "ade/initialize", params: bridgeInitializeParams() }], + { timeoutMs: 10_000 }, + ), + ).version; + + const projectResult = await jsonRpcRequestSequence( + socketPath, + [ + { method: "ade/initialize", params: bridgeInitializeParams() }, + { method: "projects.add", params: { rootPath: projectRoot } }, + ], + { timeoutMs: 10_000 }, + ); + const projectId = readProjectId(projectResult); + if (!projectId) { + throw new Error("ADE runtime did not return a projectId from projects.add."); + } + + return { + projectRoot, + socketPath, + projectId, + runtimeVersion, + }; +} + +async function callAction(state, request) { + const value = await rpcCall( + state.socketPath, + "ade/actions/call", + { + projectId: state.projectId, + name: "run_ade_action", + arguments: { + domain: request.domain, + action: request.action, + ...(request.args ? { args: request.args } : {}), + ...(Object.prototype.hasOwnProperty.call(request, "arg") ? { arg: request.arg } : {}), + ...(request.argsList ? { argsList: request.argsList } : {}), + }, + }, + 30_000, + ); + return unwrapActionResult(value, request); +} + +async function callSync(state, method, params = {}) { + return rpcCall( + state.socketPath, + method, + { projectId: state.projectId, ...params }, + 15_000, + ); +} + +async function main() { + const port = Number.parseInt(process.env.ADE_BROWSER_BRIDGE_PORT ?? "", 10) || defaultPort; + const state = await createBridgeState(); + + const server = http.createServer(async (req, res) => { + if (req.method === "OPTIONS") { + sendJson(res, 204, {}); + return; + } + + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + try { + if (req.method === "GET" && url.pathname === "/health") { + sendJson(res, 200, { + ok: true, + projectRoot: state.projectRoot, + socketPath: state.socketPath, + projectId: state.projectId, + runtimeVersion: state.runtimeVersion, + projectName: projectNameFromRoot(state.projectRoot), + }); + return; + } + + if (req.method !== "POST") { + sendJson(res, 405, { ok: false, error: "Method not allowed." }); + return; + } + + const body = await readJsonBody(req); + + if (url.pathname === "/action") { + const domain = typeof body.domain === "string" ? body.domain.trim() : ""; + const action = typeof body.action === "string" ? body.action.trim() : ""; + if (!domain || !action) { + sendJson(res, 400, { ok: false, error: "domain and action are required." }); + return; + } + const result = await callAction(state, { + domain, + action, + ...(body.args !== undefined ? { args: body.args } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "arg") ? { arg: body.arg } : {}), + ...(body.argsList ? { argsList: body.argsList } : {}), + }); + sendJson(res, 200, { ok: true, result }); + return; + } + + if (url.pathname === "/sync") { + const method = typeof body.method === "string" ? body.method.trim() : ""; + if (!method) { + sendJson(res, 400, { ok: false, error: "method is required." }); + return; + } + const params = body.params && typeof body.params === "object" && !Array.isArray(body.params) + ? body.params + : {}; + const result = await callSync(state, method, params); + sendJson(res, 200, { ok: true, result }); + return; + } + + if (url.pathname === "/lane") { + const action = typeof body.action === "string" ? body.action.trim() : ""; + if (!action) { + sendJson(res, 400, { ok: false, error: "action is required." }); + return; + } + const result = await callAction(state, { + domain: "lane", + action, + ...(body.args !== undefined ? { args: body.args } : {}), + }); + sendJson(res, 200, { ok: true, result }); + return; + } + + sendJson(res, 404, { ok: false, error: "Not found." }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendJson(res, 500, { ok: false, error: message }); + } + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + + process.stdout.write( + `[ade] browser runtime bridge listening on http://127.0.0.1:${port} (project ${state.projectRoot})\n`, + ); + + const shutdown = () => { + server.close(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/apps/desktop/scripts/dev-vite-live.mjs b/apps/desktop/scripts/dev-vite-live.mjs new file mode 100644 index 000000000..524433b13 --- /dev/null +++ b/apps/desktop/scripts/dev-vite-live.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + buildRuntimeCliForDevClient, + ensureRuntime, + resolveDevSocketPath, + resolveProjectRoot, +} from "../../../scripts/dev-shared.mjs"; + +const scriptPath = fileURLToPath(import.meta.url); +const desktopRoot = path.resolve(path.dirname(scriptPath), ".."); +const bridgeScript = path.join(desktopRoot, "scripts", "browser-runtime-bridge.mjs"); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +function parseArgs(argv) { + return { + skipRuntimeBuild: argv.includes("--skip-runtime-build"), + }; +} + +function spawnLogged(command, args, options = {}) { + const child = spawn(command, args, { + cwd: desktopRoot, + stdio: "inherit", + env: options.env ?? process.env, + ...options, + }); + return child; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const projectRoot = resolveProjectRoot(); + const socketPath = resolveDevSocketPath(); + + process.stdout.write(`[ade] vite live project root: ${projectRoot}\n`); + process.stdout.write(`[ade] vite live runtime socket: ${socketPath}\n`); + + await buildRuntimeCliForDevClient(options.skipRuntimeBuild, socketPath); + await ensureRuntime(socketPath, projectRoot); + + const bridgeEnv = { + ...process.env, + ADE_PROJECT_ROOT: projectRoot, + }; + const bridge = spawnLogged(process.execPath, [bridgeScript], { env: bridgeEnv }); + + const viteEnv = { + ...process.env, + ADE_PROJECT_ROOT: projectRoot, + }; + const vite = spawnLogged( + npmCommand, + ["run", "dev:vite"], + { env: viteEnv }, + ); + + let shuttingDown = false; + const shutdown = (signal) => { + if (shuttingDown) return; + shuttingDown = true; + if (!bridge.killed) bridge.kill(signal); + if (!vite.killed) vite.kill(signal); + }; + + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + + bridge.on("exit", (code, signal) => { + if (shuttingDown) return; + process.stderr.write( + `[ade] browser runtime bridge exited (${signal ?? code ?? "unknown"})\n`, + ); + shutdown("SIGTERM"); + }); + + vite.on("exit", (code) => { + shutdown("SIGTERM"); + process.exit(typeof code === "number" ? code : 0); + }); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 5af9289df..1f6ee3115 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -907,7 +907,7 @@ describe("linearClient", () => { }); await expect(client.listProjects()).resolves.toEqual([ - { id: "project-1", name: "App Platform", slug: "app-platform", teamName: "Platform" }, + { id: "project-1", name: "App Platform", slug: "app-platform", teamName: "Platform", icon: null, color: null }, ]); }); diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index 6bfbc6b2a..882f54894 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -277,6 +277,8 @@ export function createLinearClient(args: LinearClientArgs) { id name slug: slugId + icon + color teams { nodes { key @@ -310,7 +312,24 @@ export function createLinearClient(args: LinearClientArgs) { .map((entry) => (isRecord(entry) ? asString(entry.key) : null)) .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) : null) ?? null; - return teamKey ? { id, name, slug, teamName, teamKey } : { id, name, slug, teamName }; + return teamKey + ? { + id, + name, + slug, + teamName, + teamKey, + icon: asString(node.icon), + color: asString(node.color), + } + : { + id, + name, + slug, + teamName, + icon: asString(node.icon), + color: asString(node.color), + }; }) .filter((entry): entry is CtoLinearProject => entry != null); @@ -614,7 +633,7 @@ export function createLinearClient(args: LinearClientArgs) { const [viewer, organization, projectsConnection, teamsConnection, recentIssuesResult] = await Promise.all([ sdk.viewer, sdk.organization.catch(() => null), - sdk.projects({ first: 8, includeArchived: false } as never).catch(() => null), + sdk.projects({ first: 50, includeArchived: false } as never).catch(() => null), sdk.teams({ first: 8, includeArchived: false } as never).catch(() => null), recentIssuesPromise, ]); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 2026da36b..de0c61e2c 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -10,6 +10,7 @@ import { isWithinDir, normalizeBranchName } from "../shared/utils"; import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import { shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; +import { allocateLaneColor } from "../../../shared/laneColorPalette"; import { linearIssueBranchName, sanitizeLinearIssueBranchName } from "../../../shared/linearIssueBranch"; import { finalizeLaneLinearIssue, @@ -1107,6 +1108,13 @@ export function createLaneService({ [projectId] ); + const allocateLaneColorForProject = (): string => { + const usedColors = getAllLaneRows(false) + .map((row) => row.color) + .filter((color): color is string => typeof color === "string" && color.trim().length > 0); + return allocateLaneColor(usedColors); + }; + const getChildrenRows = (laneId: string, includeArchived = false) => db.all( includeArchived @@ -1566,15 +1574,16 @@ export function createLaneService({ const laneId = randomUUID(); const now = new Date().toISOString(); const displayName = inferLaneNameFromManagedWorktree(candidate); + const laneColor = allocateLaneColorForProject(); db.run( ` insert into lanes( id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) - values(?, ?, ?, null, 'worktree', ?, ?, ?, null, 0, null, null, null, null, 'active', ?, null) + values(?, ?, ?, null, 'worktree', ?, ?, ?, null, 0, null, ?, null, null, 'active', ?, null) `, - [laneId, projectId, displayName, defaultBaseRef, branchRef, worktreePath, now] + [laneId, projectId, displayName, defaultBaseRef, branchRef, worktreePath, laneColor, now] ); const row = getLaneRow(laneId); @@ -2034,13 +2043,14 @@ export function createLaneService({ ); linkExistingDependencyInstalls(worktreePath); + const laneColor = allocateLaneColorForProject(); db.run( ` insert into lanes( id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, runtime_placement, status, created_at, archived_at ) - values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, null, null, null, ?, ?, 'active', ?, null) + values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, ?, null, null, ?, ?, 'active', ?, null) `, [ laneId, @@ -2051,6 +2061,7 @@ export function createLaneService({ branchRef, worktreePath, args.parentLaneId, + laneColor, args.folder ?? null, runtimePlacement, now @@ -2887,6 +2898,7 @@ export function createLaneService({ if (parent && parent.status === "archived") throw new Error("Parent lane is archived"); const baseRef = args.baseBranch?.trim() || defaultBaseRef; + const laneColor = allocateLaneColorForProject(); db.run( ` @@ -2894,9 +2906,9 @@ export function createLaneService({ id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) - values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, null, null, null, 'active', ?, null) + values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, ?, null, null, 'active', ?, null) `, - [laneId, projectId, displayName, args.description ?? null, baseRef, branchRef, worktreePath, parentLaneId, now] + [laneId, projectId, displayName, args.description ?? null, baseRef, branchRef, worktreePath, parentLaneId, laneColor, now] ); laneInserted = true; invalidateLaneListCache(); @@ -4718,6 +4730,7 @@ export function createLaneService({ const parentLaneId = null; const baseRef = defaultBaseRef; + const laneColor = allocateLaneColorForProject(); db.run( ` @@ -4725,9 +4738,9 @@ export function createLaneService({ id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at ) - values(?, ?, ?, ?, 'attached', ?, ?, ?, ?, 0, ?, null, null, null, 'active', ?, null) + values(?, ?, ?, ?, 'attached', ?, ?, ?, ?, 0, ?, ?, null, null, 'active', ?, null) `, - [laneId, projectId, laneName, args.description ?? null, baseRef, branchRef, attachedPath, attachedPath, parentLaneId, now] + [laneId, projectId, laneName, args.description ?? null, baseRef, branchRef, attachedPath, attachedPath, parentLaneId, laneColor, now] ); invalidateLaneListCache(); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 0d1f46144..6c2598717 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -15,6 +15,10 @@ * when a snapshot is exported; otherwise a built-in multi-command / groups / runtime demo is used. * Work tab: `sessions` come from the snapshot when present; otherwise built-in terminal session rows * (same shape as the export script) so the session list is not empty in Vite-only previews. + * Linear: `getLinearConnectionStatus` and quick-view mocks stay in sync so the top-bar Linear + * button appears; data is synthetic unless you use the Electron dev shell (real `window.ade` IPC). + * For real Linear, sync, and lanes in Vite-only preview, run `npm run dev:vite:live` with + * `ADE_PROJECT_ROOT` pointing at your ADE project (starts the browser runtime bridge). * * Optional: generate `browser-mock-ade-snapshot.generated.json` with * npm run export:browser-mock-ade @@ -30,6 +34,7 @@ import { getDefaultModelDescriptor } from "../shared/modelRegistry"; import { DEFAULT_PIPELINE_SETTINGS } from "../shared/types"; +import { attachBrowserRuntimeBridge } from "./browserRuntimeBridge"; const noop = () => () => {}; const resolved = @@ -88,6 +93,117 @@ const MOCK_PROJECT = // ── Timestamps ──────────────────────────────────────────────── const now = new Date().toISOString(); +const MOCK_LINEAR_CONNECTION = { + tokenStored: true, + connected: true, + viewerId: "mock-linear-user", + viewerName: "Mock Linear User", + checkedAt: now, + authMode: "manual" as const, + oauthAvailable: true, + tokenExpiresAt: null, + message: null, +}; + +const MOCK_LINEAR_ISSUES = [ + { + id: "mock-linear-issue-1", + identifier: "ADE-101", + title: "Polish Work tab header layout", + description: "Align tabs, tools toggle, and lane bands in the Work chrome.", + url: "https://linear.app/ade/issue/ADE-101/polish-work-tab-header-layout", + projectId: "mock-linear-project", + projectSlug: "desktop-polish", + projectName: "Desktop polish", + teamId: "mock-linear-team", + teamKey: "ADE", + teamName: "ADE", + stateId: "mock-linear-state-started", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: [], + metadataTags: [], + assigneeId: "mock-linear-user", + assigneeName: "Mock Linear User", + creatorId: "mock-linear-user", + creatorName: "Mock Linear User", + blockerIssueIds: [], + hasOpenBlockers: false, + dueDate: null, + estimate: 3, + archivedAt: null, + completedAt: null, + canceledAt: null, + startedAt: now, + createdAt: now, + updatedAt: now, + raw: {}, + }, + { + id: "mock-linear-issue-2", + identifier: "ADE-102", + title: "Chat actions drawer parity", + description: "Unify Proof, Agents, and Handoff into one tabbed drawer.", + url: "https://linear.app/ade/issue/ADE-102/chat-actions-drawer-parity", + projectId: "mock-linear-project", + projectSlug: "desktop-polish", + projectName: "Desktop polish", + teamId: "mock-linear-team", + teamKey: "ADE", + teamName: "ADE", + stateId: "mock-linear-state-todo", + stateName: "Todo", + stateType: "unstarted", + priority: 3, + priorityLabel: "medium", + labels: [], + metadataTags: [], + assigneeId: null, + assigneeName: null, + creatorId: "mock-linear-user", + creatorName: "Mock Linear User", + blockerIssueIds: [], + hasOpenBlockers: false, + dueDate: null, + estimate: 2, + archivedAt: null, + completedAt: null, + canceledAt: null, + startedAt: null, + createdAt: now, + updatedAt: now, + raw: {}, + }, +]; + +const MOCK_LINEAR_PICKER = { + projects: [ + { + id: "mock-linear-project", + name: "Desktop polish", + slug: "desktop-polish", + teamName: "ADE", + teamKey: "ADE", + }, + ], + users: [ + { + id: "mock-linear-user", + name: "Mock Linear User", + displayName: "Mock Linear User", + email: "mock@example.com", + avatarUrl: null, + active: true, + }, + ], + states: [ + { id: "mock-linear-state-started", name: "In Progress", type: "started", teamId: "mock-linear-team" }, + { id: "mock-linear-state-todo", name: "Todo", type: "unstarted", teamId: "mock-linear-team" }, + ], +}; + /** Browser mock lane health; matches `LaneHealthCheck` in shared types. */ function mockBrowserLaneHealth(laneId: string) { return { @@ -4380,6 +4496,118 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { })(), }), }, + appControl: { + getStatus: resolved({ + platform: "darwin", + supported: false, + activeSession: null, + providers: [{ + provider: "cdp", + available: false, + detail: "Browser preview does not run App Control.", + }], + }), + launch: resolvedArg({} as any), + launchInTerminal: resolvedArg({} as any), + connect: resolvedArg({} as any), + stop: resolved({ ok: true as const, previousSession: null }), + screenshot: resolvedArg({ dataUrl: null } as any), + getSnapshot: resolvedArg({ + screenshot: null, + elements: [], + hitElement: null, + screen: { width: 0, height: 0, scale: 1 }, + } as any), + inspectPoint: resolvedArg({} as any), + selectPoint: resolvedArg({} as any), + click: resolved({ ok: true as const }), + typeText: resolved({ ok: true as const }), + scroll: resolved({ ok: true as const }), + dispatchKey: resolved({ ok: true as const }), + listTargets: resolved([]), + attachToTarget: resolvedArg({} as any), + onEvent: () => () => {}, + }, + iosSimulator: { + getStatus: resolved({ + platform: "darwin", + supported: false, + tools: [], + activeDevice: null, + activeSession: null, + }), + listDevices: resolved([]), + listLaunchTargets: resolved([]), + launch: resolvedArg({} as any), + attachToChatSession: resolved(null), + shutdown: resolvedArg({ ok: true } as any), + screenshot: resolvedArg({} as any), + getScreenSnapshot: resolvedArg({} as any), + getInspectorSnapshot: resolved(null), + inspectPoint: resolvedArg({} as any), + getPreviewCapability: resolvedArg({ supported: false } as any), + listPreviewTargets: resolved([]), + renderPreview: resolvedArg({} as any), + openPreviewWorkspace: resolved({ ok: true as const, path: "/tmp" }), + startStream: resolvedArg({ streaming: false, streamUrl: null } as any), + stopStream: resolvedArg({ streaming: false, streamUrl: null } as any), + getStreamStatus: resolvedArg({ streaming: false, streamUrl: null } as any), + getSimulatorWindowState: resolvedArg({ visible: false } as any), + listSimulatorWindowSources: resolved([]), + tap: resolved({ ok: true as const }), + typeText: resolved({ ok: true as const }), + drag: resolved({ ok: true as const }), + swipe: resolved({ ok: true as const }), + selectPoint: resolvedArg({} as any), + onEvent: () => () => {}, + }, + builtInBrowser: { + getStatus: resolved({ + attached: false, + partition: "persist:ade-browser", + visible: false, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + activeTabId: null, + tabs: [], + url: null, + title: null, + isLoading: false, + canGoBack: false, + canGoForward: false, + isInspecting: false, + hasSelection: false, + }), + showPanel: resolvedArg({} as any), + setBounds: resolvedArg({} as any), + attachWebview: resolvedArg({} as any), + navigate: resolvedArg({} as any), + createTab: resolvedArg({} as any), + switchTab: resolvedArg({} as any), + closeTab: resolvedArg({} as any), + reload: resolvedArg({} as any), + goBack: resolvedArg({} as any), + goForward: resolvedArg({} as any), + stop: resolvedArg({} as any), + startInspect: resolvedArg({} as any), + stopInspect: resolved(async () => {}), + captureScreenshot: resolvedArg({} as any), + selectPoint: resolvedArg({} as any), + selectCurrent: resolvedArg({} as any), + clearSelection: resolved({ ok: true as const }), + onEvent: () => () => {}, + }, + terminal: { + list: async (_args: any = {}) => [] as any[], + read: async () => ({ output: "", truncated: false, exitCode: null }), + preview: async () => ({ output: "", truncated: false }), + write: resolved({ ok: true as const }), + signal: resolved({ ok: true as const }), + activeForChat: async () => null, + reattachChatCli: async () => ({ + ok: false as const, + reason: "Browser mock does not attach chat CLI terminals.", + }), + }, cto: { getState: resolvedArg({ identity: ADE_DB_SNAPSHOT?.ctoState?.identity ?? { @@ -4531,17 +4759,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { onLinearWorkflowEvent: noop, getLinearProjects: resolvedArg([]), getLinearQuickView: resolvedArg({ - connection: { - tokenStored: true, - connected: true, - viewerId: "mock-linear-user", - viewerName: "Mock Linear User", - checkedAt: now, - authMode: "manual", - oauthAvailable: true, - tokenExpiresAt: null, - message: null, - }, + connection: MOCK_LINEAR_CONNECTION, organization: { id: "mock-linear-org", name: "ADE", @@ -4601,8 +4819,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { private: false, }, ], - assignedIssues: [], - recentIssues: [], + assignedIssues: MOCK_LINEAR_ISSUES, + recentIssues: MOCK_LINEAR_ISSUES, fetchedAt: now, sdk: { packageName: "@linear/sdk", @@ -4616,26 +4834,12 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { ], }, }), - getLinearIssuePickerData: resolvedArg({ - projects: [], - users: [], - states: [], - }), + getLinearIssuePickerData: resolvedArg(MOCK_LINEAR_PICKER), searchLinearIssues: resolvedArg({ - issues: [], + issues: MOCK_LINEAR_ISSUES, pageInfo: { hasNextPage: false, endCursor: null }, }), - getLinearConnectionStatus: resolvedArg({ - tokenStored: false, - connected: false, - viewerId: null, - viewerName: null, - checkedAt: now, - authMode: null, - oauthAvailable: true, - tokenExpiresAt: null, - message: "Linear token not configured.", - }), + getLinearConnectionStatus: resolvedArg(MOCK_LINEAR_CONNECTION), setLinearOAuthClient: resolvedArg({ tokenStored: false, connected: false, @@ -5556,4 +5760,5 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { updateDismissInstalledNotice: resolved(undefined), onUpdateEvent: noop, }; + void attachBrowserRuntimeBridge(); } // window diff --git a/apps/desktop/src/renderer/browserRuntimeBridge.ts b/apps/desktop/src/renderer/browserRuntimeBridge.ts new file mode 100644 index 000000000..5d44deaec --- /dev/null +++ b/apps/desktop/src/renderer/browserRuntimeBridge.ts @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +type BridgeHealth = { + ok: true; + projectRoot: string; + socketPath: string; + projectId: string; + runtimeVersion: string | null; + projectName: string; +}; + +type BridgeEnvelope = { + ok: boolean; + result?: T; + error?: string; +}; + +const BRIDGE_HEALTH_PATH = "/ade-dev-rpc/health"; +const BRIDGE_PROBE_TIMEOUT_MS = 1500; + +async function bridgePost(path: string, body: unknown): Promise { + const response = await fetch(`/ade-dev-rpc${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = (await response.json()) as BridgeEnvelope; + if (!payload.ok) { + throw new Error(payload.error ?? "Browser runtime bridge request failed."); + } + return payload.result as T; +} + +async function probeBridgeHealth(): Promise { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), BRIDGE_PROBE_TIMEOUT_MS); + try { + const response = await fetch(BRIDGE_HEALTH_PATH, { signal: controller.signal }); + if (!response.ok) return null; + const payload = (await response.json()) as BridgeHealth; + return payload.ok === true ? payload : null; + } catch { + return null; + } finally { + window.clearTimeout(timer); + } +} + +function mergeProjectFromHealth(health: BridgeHealth, existing: any) { + return { + ...(existing && typeof existing === "object" ? existing : {}), + id: health.projectId, + name: health.projectName, + rootPath: health.projectRoot, + }; +} + +function patchSyncMethods(ade: any) { + ade.sync.getStatus = async (args?: Record) => + bridgePost("/sync", { method: "sync.getStatus", params: args ?? {} }); + ade.sync.refreshDiscovery = async () => + bridgePost("/sync", { method: "sync.refreshDiscovery" }); + ade.sync.listDevices = async () => + bridgePost("/sync", { method: "sync.listDevices" }); + ade.sync.updateLocalDevice = async (args: Record) => + bridgePost("/sync", { method: "sync.updateLocalDevice", params: args }); + ade.sync.connectToBrain = async (draft: Record) => + bridgePost("/sync", { method: "sync.connectToBrain", params: draft }); + ade.sync.disconnectFromBrain = async () => + bridgePost("/sync", { method: "sync.disconnectFromBrain" }); + ade.sync.forgetDevice = async (deviceId: string) => + bridgePost("/sync", { method: "sync.forgetDevice", params: { deviceId } }); + ade.sync.getTransferReadiness = async () => + bridgePost("/sync", { method: "sync.getTransferReadiness" }); + ade.sync.transferBrainToLocal = async () => + bridgePost("/sync", { method: "sync.transferBrainToLocal" }); + ade.sync.getPin = async () => + bridgePost("/sync", { method: "sync.getPin" }); + ade.sync.setPin = async (pin: string) => + bridgePost("/sync", { method: "sync.setPin", params: { pin } }); + ade.sync.generatePin = async () => + bridgePost("/sync", { method: "sync.generatePin" }); + ade.sync.clearPin = async () => + bridgePost("/sync", { method: "sync.clearPin" }); +} + +function patchLinearMethods(ade: any) { + ade.cto.getLinearConnectionStatus = async () => + bridgePost("/action", { domain: "linear_issue_tracker", action: "getConnectionStatus" }); + ade.cto.getLinearQuickView = async () => + bridgePost("/action", { domain: "linear_issue_tracker", action: "getQuickView" }); + ade.cto.getLinearIssuePickerData = async () => + bridgePost("/action", { domain: "linear_issue_tracker", action: "getIssuePickerData" }); + ade.cto.searchLinearIssues = async (args: Record = {}) => + bridgePost("/action", { domain: "linear_issue_tracker", action: "searchIssues", args }); + ade.cto.getLinearProjects = async () => + bridgePost("/action", { domain: "linear_issue_tracker", action: "listProjects" }); + ade.cto.setLinearToken = async (args: { token: string }) => { + await bridgePost("/action", { + domain: "linear_credentials", + action: "setToken", + arg: args.token, + }); + return bridgePost("/action", { + domain: "linear_issue_tracker", + action: "getConnectionStatus", + }); + }; + ade.cto.clearLinearToken = async () => { + await bridgePost("/action", { domain: "linear_credentials", action: "clearToken" }); + return bridgePost("/action", { + domain: "linear_issue_tracker", + action: "getConnectionStatus", + }); + }; +} + +function patchLaneMethods(ade: any) { + ade.lanes.create = async (args: Record) => + bridgePost("/lane", { action: "create", args }); + ade.lanes.list = async (args: Record = {}) => + bridgePost("/lane", { action: "list", args }); +} + +export async function attachBrowserRuntimeBridge(): Promise { + if (typeof window === "undefined") return false; + const w = window as any; + if (!w.__adeBrowserMock || w.__adeRuntimeBridge) return false; + + const health = await probeBridgeHealth(); + if (!health) return false; + + const ade = w.ade; + if (!ade || typeof ade !== "object") return false; + + const project = mergeProjectFromHealth(health, ade.app?.getProject ? await ade.app.getProject().catch(() => null) : null); + + ade.app.getProject = async () => project; + ade.app.getWindowSession = async () => ({ + windowId: 1, + project, + binding: { + kind: "local", + key: `local:${health.projectRoot}`, + rootPath: health.projectRoot, + displayName: health.projectName, + }, + openProjectTabs: [project], + }); + + if (ade.sync && typeof ade.sync === "object") patchSyncMethods(ade); + if (ade.cto && typeof ade.cto === "object") patchLinearMethods(ade); + if (ade.lanes && typeof ade.lanes === "object") patchLaneMethods(ade); + + w.__adeRuntimeBridge = true; + window.dispatchEvent( + new CustomEvent("ade:runtime-bridge-ready", { + detail: { projectRoot: health.projectRoot }, + }), + ); + console.info( + `[ADE] Browser runtime bridge attached for ${health.projectRoot}`, + ); + return true; +} diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index 520bc0021..81877016c 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -16,12 +16,12 @@ import { DesktopTower, Folder, FolderOpen, - GitBranch, MagnifyingGlass, Stack, Warning, X, } from "@phosphor-icons/react"; +import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; import { motion, AnimatePresence } from "motion/react"; import { useNavigate } from "react-router-dom"; import type { @@ -1642,7 +1642,7 @@ export function CommandPalette({ "0 0 0 1px rgba(167,139,250,0.30) inset", }} > - - {detail.branchName && ( } + icon={} tone="accent" > {detail.branchName} diff --git a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx index f17d240a2..ebe0484b0 100644 --- a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx +++ b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx @@ -1,13 +1,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - ArrowSquareOut, CaretDown, + CaretRight, CircleNotch, - GitBranch, MagnifyingGlass, Plus, + Sparkle, Warning, } from "@phosphor-icons/react"; +import { BranchIcon } from "../ui/vcsIcons"; import type { CtoGetLinearIssuePickerDataResult, @@ -23,11 +24,12 @@ import { Button } from "../ui/Button"; import { issueProjectLabel, issueUpdatedLabel, - LinearIssueRow, linearPriorityLabel, toLaneLinearIssue, } from "../lanes/LinearIssuePicker"; import { LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "../lanes/linearBrand"; +import { LinearProjectIcon } from "../lanes/linearProjectIcon"; +import { LinearIssueOpenLink, type LinearIssueResolveModalKind } from "./LinearIssueResolveModals"; type BrowserIssue = NormalizedLinearIssue | LaneLinearIssue; type IssueSort = "updated_desc" | "created_desc" | "priority" | "due_soon" | "identifier_asc"; @@ -41,7 +43,14 @@ type LinearIssueBrowserFilters = { sort: IssueSort; }; +const STATE_TABS = [ + { value: "all", label: "All issues" }, + { value: "active", label: "Active" }, + { value: "backlog", label: "Backlog" }, +] as const; + const ACTIVE_LINEAR_STATE_TYPES = ["backlog", "unstarted", "started"]; +const STATE_GROUP_ORDER = ["started", "unstarted", "backlog", "triage", "completed", "canceled"] as const; const FILTER_STORAGE_PREFIX = "ade.linear.quickView.filters.v1:"; const DEFAULT_FILTERS: LinearIssueBrowserFilters = { @@ -170,17 +179,61 @@ function formatDate(value: string | null | undefined): string { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(date); } +function hasActiveFilters(filters: LinearIssueBrowserFilters): boolean { + return ( + filters.projectId !== DEFAULT_FILTERS.projectId + || filters.statePreset !== DEFAULT_FILTERS.statePreset + || filters.assigneeId !== DEFAULT_FILTERS.assigneeId + || filters.priority !== DEFAULT_FILTERS.priority + || filters.query !== DEFAULT_FILTERS.query + || filters.sort !== DEFAULT_FILTERS.sort + ); +} + +function formatLinearListDate(value: string | null | undefined): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date); +} + +function stateGroupRank(stateType: string): number { + const index = STATE_GROUP_ORDER.indexOf(stateType as typeof STATE_GROUP_ORDER[number]); + return index === -1 ? 99 : index; +} + +function groupIssuesByState(issues: BrowserIssue[]): Array<{ + key: string; + stateName: string; + stateType: string; + issues: BrowserIssue[]; +}> { + const order: string[] = []; + const groups = new Map(); + for (const issue of issues) { + const key = issue.stateId || `${issue.stateType}:${issue.stateName}`; + let group = groups.get(key); + if (!group) { + group = { stateName: issue.stateName, stateType: issue.stateType, issues: [] }; + groups.set(key, group); + order.push(key); + } + group.issues.push(issue); + } + return order + .map((key) => ({ key, ...groups.get(key)! })) + .sort((left, right) => ( + stateGroupRank(left.stateType) - stateGroupRank(right.stateType) + || left.stateName.localeCompare(right.stateName) + )); +} + function stateTypesForPreset(preset: string): string[] { if (preset === "all") return []; if (preset === "active") return ACTIVE_LINEAR_STATE_TYPES; return preset ? [preset] : []; } -function openLinearUrl(url: string | null | undefined): void { - if (!url) return; - void window.ade.app.openExternal(url); -} - export function linearBrowserIssueToLaneIssue(issue: BrowserIssue): LaneLinearIssue { return "raw" in issue ? toLaneLinearIssue(issue) : issue; } @@ -205,6 +258,7 @@ export function LinearIssueBrowser({ onConnectionVisibilityChange, onQuickViewChange, onLoadingChange, + resolveActions, }: { projectRoot?: string | null; featuredIssue?: LaneLinearIssue | null; @@ -221,6 +275,11 @@ export function LinearIssueBrowser({ onConnectionVisibilityChange?: (visible: boolean) => void; onQuickViewChange?: (quickView: CtoLinearQuickView | null) => void; onLoadingChange?: (loading: boolean) => void; + resolveActions?: { + onOpenModal: (kind: LinearIssueResolveModalKind, issue: BrowserIssue) => void; + busyModal?: LinearIssueResolveModalKind | null; + disabled?: boolean; + }; }) { const [quickView, setQuickView] = useState(null); const quickViewRef = useRef(null); @@ -234,6 +293,7 @@ export function LinearIssueBrowser({ const [loadingIssues, setLoadingIssues] = useState(false); const [localActionIssueId, setLocalActionIssueId] = useState(null); const [selectedIssueId, setSelectedIssueId] = useState(featuredIssue?.id ?? null); + const [collapsedGroups, setCollapsedGroups] = useState>({}); const [error, setError] = useState(null); const quickViewRequestIdRef = useRef(0); const searchRequestIdRef = useRef(0); @@ -378,27 +438,6 @@ export function LinearIssueBrowser({ const selectedIssue = displayIssues.find((issue) => issue.id === selectedIssueId) ?? displayIssues[0] ?? null; - const stateOptions = useMemo(() => { - const seen = new Set(); - const dynamic = catalog.states - .filter((state) => { - if (seen.has(state.type)) return false; - seen.add(state.type); - return true; - }) - .sort((left, right) => left.type.localeCompare(right.type)) - .map((state) => ({ value: state.type, label: STATE_LABELS[state.type] ?? state.type })); - const options = [ - { value: "all", label: "All states" }, - { value: "active", label: "Active" }, - ...dynamic, - ]; - if (filters.statePreset && !options.some((option) => option.value === filters.statePreset)) { - options.push({ value: filters.statePreset, label: STATE_LABELS[filters.statePreset] ?? filters.statePreset }); - } - return options; - }, [catalog.states, filters.statePreset]); - const assigneeOptions = useMemo( () => [ { value: "", label: "Anyone" }, @@ -416,6 +455,8 @@ export function LinearIssueBrowser({ })); }, [catalog.projects, quickView?.projects]); + const issueGroups = useMemo(() => groupIssuesByState(displayIssues), [displayIssues]); + const handleIssueAction = useCallback(async (issue: BrowserIssue) => { const busyIssueId = actionBusyIssueId ?? localActionIssueId; if (busyIssueId || actionDisabled) return; @@ -432,11 +473,13 @@ export function LinearIssueBrowser({ const showSettingsAction = Boolean(error && onOpenLinearSettings && isConnectionError(error)); const busyIssueId = actionBusyIssueId ?? localActionIssueId; + const filtersActive = hasActiveFilters(filters); + const issueCountLabel = issues.length > 0 ? `${issues.length}${pageInfo.hasNextPage ? "+" : ""}` : null; return ( -
+
{error ? ( -
+
{error} {showSettingsAction ? ( @@ -447,77 +490,88 @@ export function LinearIssueBrowser({
) : null} -
- -
-
+
+
- + updateFilters({ query: event.target.value })} - placeholder="Search all Linear issues" - className="h-9 w-full rounded-lg border border-white/[0.07] bg-black/20 pl-8 pr-3 text-[12px] text-fg outline-none transition-colors placeholder:text-muted-fg/40 focus:border-white/18" + placeholder="Search issues…" + className="h-8 w-full rounded-md border border-white/[0.07] bg-black/20 pl-8 pr-3 text-[12px] text-fg outline-none transition-colors placeholder:text-muted-fg/40 focus:border-white/18" />
-
- updateFilters({ statePreset: value })} - /> + +
+ {STATE_TABS.map((tab) => ( + + ))} + {loadingIssues ? : null} +
+ +
-
-
- Issues -
-
- {filters.projectId - ? projectFilters.find((projectEntry) => projectEntry.id === filters.projectId)?.name ?? "Project issues" - : "All issues"} - {loadingIssues ? : null} -
-
- -
+
{loadingQuickView && !quickView && displayIssues.length === 0 ? (
) : displayIssues.length > 0 ? ( <> - {displayIssues.map((issue) => ( - setSelectedIssueId(issue.id)} - /> - ))} + {issueGroups.map((group) => { + const collapsed = collapsedGroups[group.key] === true; + return ( +
+ + {!collapsed ? group.issues.map((issue) => ( + setSelectedIssueId(issue.id)} + /> + )) : null} +
+ ); + })} {pageInfo.hasNextPage ? (
); } +function ScopeNavButton({ + active, + title, + subtitle, + count, + onClick, +}: { + active: boolean; + title: string; + subtitle: string; + count: string | null; + onClick: () => void; +}) { + return ( + + ); +} + function ProjectFilterButton({ project, active, + count, onClick, }: { project: CtoLinearProject & { quick: CtoLinearQuickViewProject | null }; active: boolean; + count: string | null; onClick: () => void; }) { + const quick = project.quick; + return ( + ); +} + +function linearIssueListDate(issue: BrowserIssue): string { + return formatLinearListDate(issue.createdAt) || formatLinearListDate(issue.updatedAt); +} + +function LinearBrowserIssueRow({ + issue, + active, + eyebrow, + busy, + onClick, +}: { + issue: BrowserIssue; + active: boolean; + eyebrow?: string; + busy?: boolean; + onClick: () => void; +}) { + const listDate = linearIssueListDate(issue); + + return ( + ); } @@ -670,6 +812,32 @@ function FilterSelect({ ); } +const RESOLVE_ACTIONS: Array<{ + kind: LinearIssueResolveModalKind; + label: string; + description: string; + icon: React.ReactNode; +}> = [ + { + kind: "create-lane", + label: "Create lane attached to issue", + description: "New lane with this issue linked to the lane.", + icon: , + }, + { + kind: "resolve-new-lane", + label: "Resolve issue in new chat in new lane", + description: "New lane plus a Work chat with the issue linked to that chat.", + icon: , + }, + { + kind: "resolve-existing-lane", + label: "Resolve issue in new chat in existing lane", + description: "Pick a lane and start a chat with the issue linked to that chat only.", + icon: , + }, +]; + function IssueDetails({ issue, actionLabel, @@ -679,6 +847,7 @@ function IssueDetails({ actionDisabled, showBranchPreview, onIssueAction, + resolveActions, }: { issue: BrowserIssue | null; actionLabel: string; @@ -688,10 +857,15 @@ function IssueDetails({ actionDisabled: boolean; showBranchPreview: boolean; onIssueAction: (issue: BrowserIssue) => void | Promise; + resolveActions?: { + onOpenModal: (kind: LinearIssueResolveModalKind, issue: BrowserIssue) => void; + busyModal?: LinearIssueResolveModalKind | null; + disabled?: boolean; + }; }) { if (!issue) { return ( -
- +
+ undefined} + resolveActions={{ + onOpenModal: handleResolveModalOpen, + busyModal, + disabled: Boolean(busyModal), + }} + onConnectionVisibilityChange={setVisible} + onQuickViewChange={setQuickView} + onLoadingChange={setBrowserLoading} + /> +
, document.body, ) : null} + + { if (!next) closeModal(); }} + onConfirm={handleCreateLaneAttached} + /> + { if (!next) closeModal(); }} + onConfirm={handleResolveInNewLane} + /> + { if (!next) closeModal(); }} + onConfirm={handleResolveInExistingLane} + /> ); } diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index 6704aebf8..e3e9b6407 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -1,8 +1,8 @@ /* @vitest-environment jsdom */ import React from "react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { TabNav } from "./TabNav"; import { useAppStore } from "../../state/appStore"; @@ -40,6 +40,7 @@ describe("TabNav", () => { app: { revealPath: async () => undefined, getInfo: async () => ({ isPackaged: false }) as any, + openExternal: vi.fn().mockResolvedValue(undefined), }, }, }); @@ -64,4 +65,16 @@ describe("TabNav", () => { const review = screen.getByRole("link", { name: "Review" }); expect(prs.nextElementSibling).toBe(review); }); + + it("opens the connected GitHub profile from the sidebar avatar", () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Open GitHub profile for arul28" })); + + expect(globalThis.window.ade.app.openExternal).toHaveBeenCalledWith("https://github.com/arul28"); + }); }); diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index 6141c2dc8..5c5a8379b 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -17,6 +17,7 @@ import { import { cn } from "../ui/cn"; import { useAppStore } from "../../state/appStore"; import { revealLabel } from "../../lib/platform"; +import { openExternalUrl } from "../../lib/openExternal"; import { logRendererDebugEvent } from "../../lib/debugLog"; import type { GitHubStatus } from "../../../shared/types"; import { readStoredPrsRoute } from "../prs/prsRouteState"; @@ -45,6 +46,10 @@ function primaryTabPath(pathname: string): string { return pathname === settingsItem.to || pathname.startsWith(`${settingsItem.to}/`) ? settingsItem.to : pathname; } +function githubProfileUrl(login: string): string { + return `https://github.com/${encodeURIComponent(login)}`; +} + export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) { const project = useAppStore((s) => s.project); const showWelcome = useAppStore((s) => s.showWelcome); @@ -230,19 +235,24 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) {/* GitHub profile avatar — only shows when token is stored, a login is known, and the image loads */} {githubLogin && !avatarBroken ? ( -
+
+ ) : null} {/* Divider line before settings */} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 8da7c3c40..ed85fdbfb 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -412,11 +412,11 @@ describe("TopBar", () => { try { render(); - expect(screen.getByText("Phone sync")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); await advancePhoneSyncStartupDelay(); - expect(screen.getByText("1 phone connected to ADE Desktop")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Mobile, connected" })).toBeTruthy(); fireEvent.click(screen.getByTitle("Connect a phone to this machine")); @@ -449,7 +449,7 @@ describe("TopBar", () => { render(); await advancePhoneSyncStartupDelay(); - expect(screen.getByText("Phone sync ready")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); await act(async () => { syncEventHandler?.({ @@ -462,7 +462,7 @@ describe("TopBar", () => { }); }); - expect(screen.getByText("1 phone connected to ADE Desktop")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Mobile, connected" })).toBeTruthy(); } finally { vi.useRealTimers(); } @@ -486,7 +486,7 @@ describe("TopBar", () => { render(); await advancePhoneSyncStartupDelay(); - expect(screen.getByText("Phone sync unavailable")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); expect(screen.queryByText("Phone sync ready")).toBeNull(); } finally { vi.useRealTimers(); @@ -533,14 +533,14 @@ describe("TopBar", () => { await flushMicrotasks(2); }); - expect(screen.getByText("Phone sync ready")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); await act(async () => { window.dispatchEvent(new Event("focus")); await flushMicrotasks(2); }); - expect(screen.getByText("1 phone connected to ADE Desktop")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Mobile, connected" })).toBeTruthy(); expect(getStatus).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); @@ -658,7 +658,8 @@ describe("TopBar", () => { expect(document.body.querySelector("[data-linear-quick-view-backdrop]")).toBeTruthy(); expect(quickViewDialog.getAttribute("style")).toContain("rgba(123, 138, 240, 0.55)"); - fireEvent.click(screen.getByRole("button", { name: /create lane/i })); + fireEvent.click(screen.getByRole("button", { name: /create lane attached to issue/i })); + fireEvent.click(await screen.findByRole("button", { name: /^create lane$/i })); await waitFor(() => { expect(createLane).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 165fbd1d1..d20edb23c 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -5,6 +5,7 @@ import React, { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { ArrowSquareOut, ChatCircleDots, @@ -15,6 +16,7 @@ import { FolderOpen, Plus, Minus, + Plugs, Trash, UploadSimple, X, @@ -257,11 +259,217 @@ function getFocusableElements(root: HTMLElement): HTMLElement[] { ); } -function syncDotClass(snapshot: SyncRoleSnapshot): string { - if (snapshot.client.state === "error") return "ade-status-dot-error"; - if (snapshot.client.state === "connected" || snapshot.role === "brain") - return "ade-status-dot-active"; - return "ade-status-dot-warning"; +function isSyncConnected(snapshot: SyncRoleSnapshot | null): boolean { + if (!snapshot) return false; + const unavailableReason = typeof snapshot.localDevice.metadata?.unavailableReason === "string" + ? snapshot.localDevice.metadata.unavailableReason + : null; + if ( + unavailableReason === "local_runtime_daemon_disabled" + || snapshot.localDevice.deviceId === "local-runtime-disabled" + ) { + return false; + } + if (snapshot.client.state === "error") return false; + if (snapshot.role === "brain") return snapshot.connectedPeers.length > 0; + return snapshot.client.state === "connected"; +} + +const HEADER_STATUS_COMPACT_MAX_WIDTH_PX = 767; + +function useHeaderStatusCompactLayout(): boolean { + const [compact, setCompact] = useState(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia(`(max-width: ${HEADER_STATUS_COMPACT_MAX_WIDTH_PX}px)`).matches; + }); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const mq = window.matchMedia(`(max-width: ${HEADER_STATUS_COMPACT_MAX_WIDTH_PX}px)`); + const update = () => setCompact(mq.matches); + update(); + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + }, []); + + return compact; +} + +const HEADER_STATUS_MENU_ROW_CLASS = + "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-[11px] font-medium text-muted-fg/80 transition-colors duration-150 hover:bg-white/[0.06] hover:text-fg/90"; + +function ShellConnectionChip({ + label, + icon, + connected, + title, + ariaExpanded, + onClick, + layout = "chip", +}: { + label: string; + icon: React.ReactNode; + connected: boolean; + title: string; + ariaExpanded?: boolean; + onClick: () => void; + layout?: "chip" | "menu-row"; +}) { + return ( + + ); +} + +function HeaderStatusMenu({ + remoteConnected, + syncConnected, + showSyncControl, + children, +}: { + remoteConnected: boolean; + syncConnected: boolean; + showSyncControl: boolean; + children: (close: () => void) => React.ReactNode; +}) { + const compact = useHeaderStatusCompactLayout(); + const [open, setOpen] = useState(false); + const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + const close = useCallback(() => { + setOpen(false); + setMenuPos(null); + }, []); + + const openMenu = useCallback(() => { + const el = buttonRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setMenuPos({ + top: rect.bottom + 6, + right: Math.max(8, window.innerWidth - rect.right), + }); + setOpen(true); + }, []); + + useEffect(() => { + if (!compact) close(); + }, [close, compact]); + + useEffect(() => { + if (!open) return; + const onDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (menuRef.current?.contains(target)) return; + if (buttonRef.current?.contains(target)) return; + close(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [close, open]); + + const anyConnected = remoteConnected || (showSyncControl && syncConnected); + + if (!compact) return null; + + return ( + <> + + {open && menuPos + ? createPortal( +
+ {children(close)} +
, + document.body, + ) + : null} + + ); } function projectIconErrorMessage(error: unknown): string { @@ -400,7 +608,7 @@ function ProjectTabIcon({ const fallbackIcon = ( event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} @@ -504,7 +712,7 @@ function ProjectTabIcon({ aria-label="Project icon" title="Project icon" className={cn( - "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-[5px]", + "inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px]", "text-current transition-colors hover:bg-white/10 focus-visible:outline focus-visible:outline-1 focus-visible:outline-accent/70", )} onClick={(event) => event.stopPropagation()} @@ -692,8 +900,8 @@ export function TopBar() { hasGitHubRemote === false && hasOrigin === false; const connectedRemoteCount = remoteSnapshot?.connectedCount ?? 0; - const remoteButtonLabel = - connectedRemoteCount > 0 ? `Remote ${connectedRemoteCount}` : "Remote"; + const remoteConnected = connectedRemoteCount > 0; + const syncConnected = isSyncConnected(syncSnapshot); const showSyncControl = workspaceProjectOpen; useEffect(() => { @@ -1386,6 +1594,88 @@ export function TopBar() { ); const syncLabel = deriveSyncLabel(syncSnapshot) ?? "Phone sync"; + + const renderHeaderStatusControls = useCallback( + (options?: { menuLayout?: boolean; onActivate?: () => void }) => { + const menuLayout = options?.menuLayout === true; + const wrapActivate = (handler: () => void) => () => { + handler(); + options?.onActivate?.(); + }; + + const remoteChip = ( + setRemotePanelOpen(true)) + : () => setRemotePanelOpen((open) => !open) + } + icon={( + + )} + /> + ); + + const mobileChip = showSyncControl ? ( + setPhoneSyncOpen(true)) + : () => setPhoneSyncOpen((open) => !open) + } + icon={( + + )} + /> + ) : null; + + if (menuLayout) { + return ( +
+ + + {remoteChip} + {mobileChip} +
+ ); + } + + return ( + <> + + {remoteChip} + {mobileChip} + + + ); + }, + [ + phoneSyncOpen, + remoteConnected, + remotePanelOpen, + showSyncControl, + syncConnected, + ], + ); + const transitionTargetName = projectTransition?.rootPath ? (projectTabs.find( (entry) => entry.rootPath === projectTransition.rootPath, @@ -1421,12 +1711,12 @@ export function TopBar() { src="./logo.png" alt="ADE" className="shrink-0 select-none" - style={{ height: 26 }} + style={{ height: 20 }} draggable={false} /> {/* Divider */} -
+
{/* Project tabs — the container stays draggable, only interactive elements opt out */}
handleDrop(e, idx)} onDragEnd={(e) => handleDragEnd(e, rp.rootPath)} className={cn( - "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] min-w-0 shrink-0 items-center gap-2 px-3 py-0.5", + "ade-shell-project-tab group inline-flex w-[clamp(128px,16vw,220px)] max-w-[220px] min-w-0 shrink-0 items-center gap-1.5 px-2.5", "transition-[background-color,color,border-color,box-shadow,opacity] duration-150", !isMissing && "cursor-pointer", isCurrent && "font-semibold", @@ -1610,7 +1900,7 @@ export function TopBar() { + + {(closeMenu) => renderHeaderStatusControls({ menuLayout: true, onActivate: closeMenu })} + - {showSyncControl ? ( - - ) : null} - - -
- {/* /status group */} - -
- - - + data-variant="ghost" + onClick={() => setFeedbackOpen(true)} + title="Report bug or suggest feature" + aria-label="Report bug or suggest feature" + > + + - -
- {/* /actions group */} + - {/* Zoom controls (view group) */} -
- - - {zoom}% - - -
+
+ + + {zoom}% + + +
+
- {/* /trailing groups */} {/* Overlay panels & modals — kept outside the gap-6 wrapper so they never participate in flex gap accounting when toggled open. */} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 28b5860f0..bf4350652 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -397,7 +397,7 @@ describe("AgentChatComposer", () => { expect(screen.queryByRole("button", { name: "Chat" })).toBeNull(); const trigger = screen.getByRole("button", { name: "Claude permission mode" }); - expect(trigger.textContent).toContain("Ask permissions"); + expect(trigger.textContent).toContain("Ask"); fireEvent.click(trigger); @@ -485,6 +485,22 @@ describe("AgentChatComposer", () => { expect(screen.queryByDisplayValue("Workspace write")).toBeNull(); }); + it("wires permission preset triggers to composer container-query compact layout", () => { + const { container } = renderComposer({ + sessionProvider: "codex", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + }); + + expect(container.querySelector(".ade-chat-composer-footer")).toBeTruthy(); + + const trigger = screen.getByRole("button", { name: "Codex approval preset" }); + expect(trigger.className).toContain("ade-chat-composer-permission-trigger"); + expect(trigger.querySelector(".ade-chat-composer-permission-label")).toBeTruthy(); + expect(trigger.querySelector(".ade-chat-composer-permission-chevron")).toBeTruthy(); + }); + it("maps Codex preset buttons to the underlying approval and sandbox controls", () => { const onCodexPresetChange = vi.fn(); renderComposer({ onCodexPresetChange }); @@ -843,7 +859,6 @@ describe("AgentChatComposer", () => { const button = screen.getByRole("button", { name: "Orchestrator mode active" }); expect(button.getAttribute("aria-pressed")).toBe("true"); - expect(container.querySelector("[data-chat-composer-orchestrator-effects]")).toBeTruthy(); expect(container.querySelector("[data-chat-composer-orchestrator-glow]")).toBeTruthy(); fireEvent.click(button); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 28916363e..3cc75de23 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ArrowBendDownRight, At, Bug, CaretDown, Check, CloudArrowUp, Cube, Desktop, DeviceMobile, GithubLogo, Globe, Image, Lightning, PaperPlaneTilt, Paperclip, PencilSimple, Plus, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; +import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, CloudArrowUp, Cube, Desktop, DeviceMobile, GithubLogo, Globe, Image, Lightning, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; import { inferAttachmentType, @@ -443,6 +443,26 @@ type ClaudeModeOption = { tone: ClaudeModeTone; }; +function composerPermissionLabel(label: string): string { + const SHORT: Record = { + "Ask permissions": "Ask", + "Accept edits": "Edits", + "Bypass permissions": "Bypass", + "Plan mode": "Plan", + }; + return SHORT[label] ?? label; +} + +const COMPOSER_TOOLBAR_PICKER_TRIGGER = "max-w-[min(9.5rem,34vw)] shrink min-w-0"; + +const COMPOSER_PERMISSION_TRIGGER_CLASS = cn( + "ade-chat-composer-permission-trigger", + "inline-flex h-6 min-w-0 shrink-0 items-center justify-start gap-1 rounded-md border px-1.5", + "font-sans text-[length:calc(var(--chat-font-size)*9/14)] leading-none transition-colors duration-150", + "border-white/[0.06] bg-white/[0.03] text-fg/80", + "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", +); + const CLAUDE_MODE_OPTIONS: ClaudeModeOption[] = [ { value: "default", label: "Ask permissions", detail: "Claude asks before edits, Bash, and other sensitive tools.", tone: "green" }, { value: "auto", label: "Auto", detail: "Claude judges each tool call. Uses a model classifier instead of asking you.", tone: "amber" }, @@ -2058,21 +2078,19 @@ export function AgentChatComposer({ setClaudeModePickerOpen((open) => !open); }} className={cn( - "inline-flex h-8 min-w-0 items-center gap-1.5 rounded-md border px-2 font-sans text-[length:calc(var(--chat-font-size)*11/14)] leading-none transition-colors duration-150", - "border-white/[0.06] bg-white/[0.03] text-fg/80", - "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", + COMPOSER_PERMISSION_TRIGGER_CLASS, claudeModePickerOpen && "border-violet-400/30 bg-violet-500/[0.08] text-fg", nativeControlsDisabled && "cursor-not-allowed opacity-60 hover:border-white/[0.06] hover:bg-white/[0.03]", )} title={selectedOption.detail} > - {selectedOption.label} + {composerPermissionLabel(selectedOption.label)} @@ -2152,9 +2170,7 @@ export function AgentChatComposer({ setCodexPresetPickerOpen((open) => !open); }} className={cn( - "inline-flex h-8 min-w-0 items-center gap-1.5 rounded-md border px-2 font-sans text-[length:calc(var(--chat-font-size)*11/14)] leading-none transition-colors duration-150", - "border-white/[0.06] bg-white/[0.03] text-fg/80", - "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", + COMPOSER_PERMISSION_TRIGGER_CLASS, codexPresetPickerOpen && "border-violet-400/30 bg-violet-500/[0.08] text-fg", nativeControlsDisabled && "cursor-not-allowed opacity-60 hover:border-white/[0.06] hover:bg-white/[0.03]", )} @@ -2165,13 +2181,15 @@ export function AgentChatComposer({ className={cn("h-1.5 w-1.5 shrink-0 rounded-full", safetyDotClass(activePreset.safety))} aria-hidden /> - ) : null} - {presetLabel} + ) : ( + + )} + {presetLabel} @@ -2452,23 +2470,6 @@ export function AgentChatComposer({ parallelModelSlots, ]); - const composerToolbarReasoningVisible = useMemo(() => { - if (parallelChatMode) return false; - const id = modelId?.trim(); - if (!id) return false; - return (resolveModelDescriptorWithRuntimeCatalog(id)?.reasoningTiers?.length ?? 0) > 0; - }, [parallelChatMode, modelId]); - - const composerToolbarGridMode = useMemo<"flex" | "grid2" | "grid3">(() => { - if (parallelChatMode) return "flex"; - const hasNative = Boolean(nativeControlPanel); - const reasoning = composerToolbarReasoningVisible; - const total = (hasNative ? 1 : 0) + 1 + (reasoning ? 1 : 0); - if (total <= 1) return "flex"; - if (total === 2) return "grid2"; - return "grid3"; - }, [parallelChatMode, nativeControlPanel, composerToolbarReasoningVisible]); - const composerGlowColor = useMemo(() => { if (orchestratorModeActive) return "rgba(217, 70, 239, 0.36)"; const provider = sessionProvider ?? (modelId ? "anthropic" : null); @@ -3360,7 +3361,7 @@ export function AgentChatComposer({ } footer={ -
+
{parallelChatMode ? (
@@ -3457,23 +3458,23 @@ export function AgentChatComposer({ ) : null}
) : null} -
+
{/* Left: permission + model controls */} -
+
{(() => { const showNativeControls = !parallelChatMode || (parallelConfiguringIndex != null && parallelModelSlots[parallelConfiguringIndex]); if (!showNativeControls || !nativeControlPanel) return null; - const wrapForUniformHeight = !parallelChatMode && composerToolbarGridMode !== "flex"; + const wrapForUniformHeight = !parallelChatMode; if (!wrapForUniformHeight) return nativeControlPanel; return (
{nativeControlPanel} @@ -3524,6 +3525,7 @@ export function AgentChatComposer({ {...(onRuntimeCatalogRefreshed ? { onRuntimeCatalogRefreshed } : {})} disabled={parallelLaunchBusy} compact + triggerClassName={COMPOSER_TOOLBAR_PICKER_TRIGGER} fastModeActive={fastModeActive} fastModeSupported={fastModeSupported} onFastModeToggle={(next) => onParallelSlotCodexFastModeChange?.(parallelConfiguringIndex, next)} @@ -3534,6 +3536,7 @@ export function AgentChatComposer({ onChange={(effort) => onParallelSlotReasoningChange?.(parallelConfiguringIndex, effort)} disabled={parallelLaunchBusy} compact + triggerClassName={COMPOSER_TOOLBAR_PICKER_TRIGGER} /> ) : null} @@ -3550,6 +3553,7 @@ export function AgentChatComposer({ {...(onRuntimeCatalogRefreshed ? { onRuntimeCatalogRefreshed } : {})} disabled={modelSelectionLocked} compact + triggerClassName={COMPOSER_TOOLBAR_PICKER_TRIGGER} fastModeActive={fastModeActive} fastModeSupported={fastModeSupported} onFastModeToggle={onCodexFastModeChange} @@ -3560,6 +3564,7 @@ export function AgentChatComposer({ onChange={onReasoningEffortChange} disabled={modelSelectionLocked} compact + triggerClassName={COMPOSER_TOOLBAR_PICKER_TRIGGER} /> ) : null} @@ -3570,8 +3575,9 @@ export function AgentChatComposer({ ) : null} {/* Right: attachment, commands, proof, context, send */} -
+
) : null} @@ -3810,45 +3819,39 @@ export function AgentChatComposer({ : "Send this prompt to the selected model."; return ( <> - + {onSubmitInBackground && !parallelChatMode && !cloudMode ? ( - + ) : null} @@ -4180,7 +4183,7 @@ function CursorCloudActionMenu({ ref={triggerRef} onClick={() => setOpen((v) => !v)} className={cn( - "relative inline-flex h-8 min-w-8 items-center justify-center gap-1 rounded-lg border px-1.5 font-sans text-[length:calc(var(--chat-font-size)*10/14)] font-medium transition-colors", + "relative inline-flex h-7 min-w-7 items-center justify-center gap-1 rounded-lg border px-1.5 font-sans text-[length:calc(var(--chat-font-size)*9/14)] font-medium transition-colors", active ? "border-violet-300/30 bg-violet-500/[0.16] text-violet-100/90" : "border-white/[0.06] bg-white/[0.02] text-muted-fg/30 hover:border-violet-300/22 hover:text-violet-200/80", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index d1afea01c..1d06f5e73 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -91,7 +91,7 @@ import { shouldRefreshSessionListForChatEvent, } from "../../lib/chatSessionEvents"; import { SmartTooltip } from "../ui/SmartTooltip"; -import { ChatSurfaceShell } from "./ChatSurfaceShell"; +import { CHAT_SHELL_HEADER_CLASS, ChatSurfaceShell } from "./ChatSurfaceShell"; import { OrchestratorLeadFrame } from "./OrchestratorLeadFrame"; import { OrchestrationPanel } from "../orchestration/OrchestrationPanel"; import { chatChipToneClass, providerChatAccent } from "./chatSurfaceTheme"; @@ -109,17 +109,20 @@ import { ChatCursorCloudPanel, type ChatCursorCloudPanelHandle } from "./ChatCur import { CursorCloudInlineLaunch, type CursorCloudInlineLaunchHandle } from "./CursorCloudInlineLaunch"; import { QuickRunMenu } from "../run/QuickRunMenu"; import { ChatGitToolbar } from "./ChatGitToolbar"; +import { LaneChip } from "../terminals/LaneChip"; +import { getLaneAccent } from "../lanes/laneColorPalette"; +import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; import { ChatTerminalDrawer, ChatTerminalToggle } from "./ChatTerminalDrawer"; import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries } from "./chatExecutionSummary"; import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; -import { useClickOutside } from "../../hooks/useClickOutside"; +import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel"; import { useAppStore } from "../../state/appStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; import { LaneAccentDot } from "../lanes/LaneAccentDot"; -import { LaneCombobox } from "../terminals/LaneCombobox"; +import { LaneCombobox, AUTO_CREATE_LANE_OPTION_ID } from "../terminals/LaneCombobox"; import { buildTrackedCliLaunchCommand, LAUNCH_PROFILE_TITLE, @@ -164,11 +167,10 @@ const workCliStartupDelayMs = 180; export const DEFAULT_PARALLEL_ATTACHMENT_REQUEST = "Please review the attached files."; const chatToolbarActionBase = - "relative inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border px-2.5 font-sans text-[10px] font-medium transition-colors"; + "relative inline-flex h-6 shrink-0 items-center gap-1 rounded-md border px-2 font-sans text-[10px] font-medium transition-colors"; const chatToolbarActionIdle = "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65"; -const AUTO_CREATE_LANE_OPTION_ID = "__ade_auto_create_lane__"; const AUTO_CREATE_LANE_OPTION = { id: AUTO_CREATE_LANE_OPTION_ID, name: "Auto-create lane", @@ -1736,19 +1738,26 @@ function completionBadgeClass(status: NonNullable(); function chatCompanionUiStorageKey(key: string): string { @@ -1761,9 +1770,13 @@ function readChatCompanionUiState(key: string): ChatCompanionUiState { try { const raw = window.sessionStorage.getItem(chatCompanionUiStorageKey(key)); if (raw) { - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(raw) as Partial & { proofDrawerOpen?: boolean }; + const legacyProofOpen = parsed.proofDrawerOpen === true; const state = { - proofDrawerOpen: parsed.proofDrawerOpen === true, + chatActionsOpen: parsed.chatActionsOpen === true || legacyProofOpen, + chatActionsTab: legacyProofOpen && parsed.chatActionsTab == null + ? "proof" + : parseChatActionsTab(parsed.chatActionsTab), iosSimulatorOpen: parsed.iosSimulatorOpen === true, appControlOpen: parsed.appControlOpen === true, terminalDrawerOpen: parsed.terminalDrawerOpen === true, @@ -1813,6 +1826,8 @@ export function AgentChatPane({ isTileVisible = isTileActive, shouldAutofocusComposer = false, initialLinearIssueContext = null, + initialLinearIssueContextSource = "lane_link", + initialModelId = null, onInitialLinearIssueContextConsumed, onSessionCreated, workDraftKind = "chat", @@ -1845,6 +1860,8 @@ export function AgentChatPane({ isTileVisible?: boolean; shouldAutofocusComposer?: boolean; initialLinearIssueContext?: LaneLinearIssue | null; + initialLinearIssueContextSource?: "manual" | "lane_link"; + initialModelId?: string | null; onInitialLinearIssueContextConsumed?: () => void; onSessionCreated?: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; workDraftKind?: "chat" | "cli" | "chat-orchestrator"; @@ -1990,8 +2007,11 @@ export function AgentChatPane({ const [error, setError] = useState(null); const [deletingChatSessionId, setDeletingChatSessionId] = useState(null); const [computerUseSnapshot, setComputerUseSnapshot] = useState(null); - const [proofDrawerOpen, setProofDrawerOpen] = useState( - () => readChatCompanionUiState(initialCompanionStateKey).proofDrawerOpen, + const [chatActionsOpen, setChatActionsOpen] = useState( + () => readChatCompanionUiState(initialCompanionStateKey).chatActionsOpen, + ); + const [chatActionsTab, setChatActionsTab] = useState( + () => readChatCompanionUiState(initialCompanionStateKey).chatActionsTab, ); const [iosSimulatorOpen, setIosSimulatorOpen] = useState( () => readChatCompanionUiState(initialCompanionStateKey).iosSimulatorOpen, @@ -1999,7 +2019,6 @@ export function AgentChatPane({ const [iosSimulatorDrawerModeRequest, setIosSimulatorDrawerModeRequest] = useState<{ mode: IosSimulatorDrawerMode; nonce: number } | null>(null); const [iosSimulatorAvailable, setIosSimulatorAvailable] = useState(isLikelyMacRenderer); const [cursorCloudPaneOpen, setCursorCloudPaneOpen] = useState(false); - const [subagentPaneOpen, setSubagentPaneOpen] = useState(false); // Subagent drill-in: when set, the chat surface renders the named subagent's // transcript instead of the parent stream and the composer is disabled. const [subagentView, setSubagentView] = useState<{ @@ -2088,7 +2107,6 @@ export function AgentChatPane({ sessionId: string; envelope: AgentChatEventEnvelope; } | null>(null); - const [handoffOpen, setHandoffOpen] = useState(false); const [handoffBusy, setHandoffBusy] = useState(false); const [handoffModelId, setHandoffModelId] = useState(""); const [handoffReasoningEffort, setHandoffReasoningEffort] = useState(null); @@ -2158,7 +2176,6 @@ export function AgentChatPane({ const lastComputerUseSnapshotRef = useRef<{ sessionId: string; fetchedAt: number } | null>(null); const knownSessionIdsRef = useRef>(new Set()); const seededInitialSummaryRef = useRef(false); - const handoffRef = useRef(null); const localTouchBySessionRef = useRef>(new Map()); const cursorWarmupKeyRef = useRef(null); const draftLaunchConfigHydratedRef = useRef(null); @@ -2221,7 +2238,8 @@ export function AgentChatPane({ useEffect(() => { companionHydrationKeyRef.current = companionStateKey; const saved = readChatCompanionUiState(companionStateKey); - setProofDrawerOpen(saved.proofDrawerOpen); + setChatActionsOpen(saved.chatActionsOpen); + setChatActionsTab(saved.chatActionsTab); setIosSimulatorOpen(saved.iosSimulatorOpen); setAppControlOpen(saved.appControlOpen); setTerminalDrawerOpen(saved.terminalDrawerOpen); @@ -2233,12 +2251,13 @@ export function AgentChatPane({ return; } writeChatCompanionUiState(companionStateKey, { - proofDrawerOpen, + chatActionsOpen, + chatActionsTab, iosSimulatorOpen, appControlOpen, terminalDrawerOpen, }); - }, [appControlOpen, companionStateKey, iosSimulatorOpen, proofDrawerOpen, terminalDrawerOpen]); + }, [appControlOpen, chatActionsOpen, chatActionsTab, companionStateKey, iosSimulatorOpen, terminalDrawerOpen]); const removeIosElementContext = useCallback((id: string) => { let linkedAttachmentPath: string | null = null; @@ -2535,11 +2554,10 @@ export function AgentChatPane({ useEffect(() => { if (!selectedSessionId) { - if (subagentPaneOpen) setSubagentPaneOpen(false); + if (chatActionsOpen) setChatActionsOpen(false); return; } if (selectedSubagentSnapshots.length === 0) { - if (subagentPaneOpen) setSubagentPaneOpen(false); return; } if (subagentAutoOpenedSessionsRef.current.has(selectedSessionId)) { @@ -2562,14 +2580,14 @@ export function AgentChatPane({ } catch { /* best-effort persistence */ } - if (!subagentPaneOpen) { - setProofDrawerOpen(false); + if (!chatActionsOpen) { + setChatActionsTab("agents"); setIosSimulatorOpen(false); setAppControlOpen(false); setCursorCloudPaneOpen(false); - setSubagentPaneOpen(true); + setChatActionsOpen(true); } - }, [selectedSessionId, selectedSubagentSnapshots.length, subagentPaneOpen]); + }, [chatActionsOpen, selectedSessionId, selectedSubagentSnapshots.length]); const persistParallelLaunchState = useCallback(async (state: AgentChatParallelLaunchState | null) => { if (!projectRoot || !laneId) return; @@ -3060,6 +3078,9 @@ export function AgentChatPane({ const launchModeEditable = !selectedSessionId || selectedEvents.length === 0; const resolvedTitle = presentation?.title?.trim() || (surfaceMode === "resolver" ? "AI Resolver" : selectedSession ? chatSessionTitle(selectedSession) : "New chat"); + const chatHeaderLane = laneId ? lanes.find((lane) => lane.id === laneId) ?? null : null; + const chatHeaderLaneName = chatHeaderLane?.name ?? laneId ?? "lane"; + const chatHeaderLaneColor = getLaneAccent(chatHeaderLane, 0); const assistantLabel = presentation?.assistantLabel?.trim() || resolveAssistantLabel(selectedModelDesc, selectedSession?.provider); const defaultMessagePlaceholder = @@ -3213,6 +3234,7 @@ export function AgentChatPane({ && !isPersistentIdentitySurface && (selectedSession.surface ?? "work") === "work", ); + const chatActionsHandoffActive = chatActionsOpen && chatActionsTab === "handoff"; const handoffTargetDescriptor = useMemo( () => (handoffModelId ? (getModelById(handoffModelId) ?? null) : null), [handoffModelId], @@ -3950,10 +3972,9 @@ export function AgentChatPane({ if (eventLaneId && laneId && eventLaneId !== laneId) return; if (!eventChatSessionId && !eventLaneId && !isTileActive) return; setIosSimulatorAvailable(true); - setProofDrawerOpen(false); + setChatActionsOpen(false); setAppControlOpen(false); setCursorCloudPaneOpen(false); - setSubagentPaneOpen(false); setIosSimulatorOpen(true); setIosSimulatorDrawerModeRequest({ mode: event.mode, nonce: Date.now() }); }); @@ -3979,14 +4000,8 @@ export function AgentChatPane({ knownSessionIdsRef.current = next; }, [initialSessionId, lockSessionId, selectedSessionId, sessions]); - const shouldKeepHandoffOpenForPortalClick = useCallback((target: Node) => { - return Array.from(document.querySelectorAll("[data-model-picker-panel='true']")) - .some((panel) => panel.contains(target)); - }, []); - useClickOutside(handoffRef, () => setHandoffOpen(false), handoffOpen, shouldKeepHandoffOpenForPortalClick); - useEffect(() => { - if (!handoffOpen) return; + if (!chatActionsHandoffActive) return; const preferredTargetId = handoffAvailableModelIds.find((id) => id !== selectedSessionModelId) ?? handoffAvailableModelIds[0] ?? ""; setHandoffModelId((current) => { if (current && handoffAvailableModelIds.includes(current)) { @@ -3994,11 +4009,11 @@ export function AgentChatPane({ } return preferredTargetId; }); - }, [handoffAvailableModelIds, handoffOpen, selectedSessionModelId]); + }, [chatActionsHandoffActive, handoffAvailableModelIds, selectedSessionModelId]); const prevHandoffOpenRef = useRef(false); useEffect(() => { - if (handoffOpen && !prevHandoffOpenRef.current) { + if (chatActionsHandoffActive && !prevHandoffOpenRef.current) { setHandoffReasoningEffort(reasoningEffort ?? null); setHandoffCodexFastMode(codexFastMode); setHandoffClaudePermissionMode(claudePermissionMode); @@ -4010,15 +4025,15 @@ export function AgentChatPane({ setHandoffCursorModeId(cursorModeId); setHandoffCursorConfigValues({ ...cursorConfigValues }); } - prevHandoffOpenRef.current = handoffOpen; + prevHandoffOpenRef.current = chatActionsHandoffActive; // Intentional: one-shot on open; avoid resetting the handoff form when underlying composer state changes while the menu is open. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handoffOpen]); + }, [chatActionsHandoffActive]); useEffect(() => { - if (!handoffOpen || !handoffModelId) return; + if (!chatActionsHandoffActive || !handoffModelId) return; setHandoffReasoningEffort((prev) => clampHandoffReasoningToModel(prev, handoffTargetDescriptor)); - }, [handoffOpen, handoffModelId, handoffTargetDescriptor]); + }, [chatActionsHandoffActive, handoffModelId, handoffTargetDescriptor]); useEffect(() => { if (!isTileVisible) return; @@ -4111,7 +4126,7 @@ export function AgentChatPane({ setAttachments([]); setContextAttachments([]); setPromptSuggestion(null); - setHandoffOpen(false); + setChatActionsOpen(false); setHandoffBusy(false); optimisticOutgoingMessageRef.current = null; setOptimisticOutgoingMessage(null); @@ -4397,8 +4412,7 @@ export function AgentChatPane({ useEffect(() => { if (!selectedSessionId) { - setProofDrawerOpen(false); - setSubagentPaneOpen(false); + setChatActionsOpen(false); } }, [selectedSessionId]); @@ -4684,10 +4698,23 @@ export function AgentChatPane({ if (consumedInitialLinearIssueContextRef.current === key) return; consumedInitialLinearIssueContextRef.current = key; setContextAttachments((prev) => mergeChatContextAttachments(prev, [ - makeLinearIssueContextAttachment(initialLinearIssueContext, "lane_link"), + makeLinearIssueContextAttachment(initialLinearIssueContext, initialLinearIssueContextSource), ])); onInitialLinearIssueContextConsumed?.(); - }, [initialLinearIssueContext, onInitialLinearIssueContextConsumed]); + }, [initialLinearIssueContext, initialLinearIssueContextSource, onInitialLinearIssueContextConsumed]); + + const consumedInitialModelIdRef = useRef(null); + useEffect(() => { + const nextModelId = initialModelId?.trim() || ""; + if (!nextModelId) { + consumedInitialModelIdRef.current = null; + return; + } + if (!preferencesReady) return; + if (consumedInitialModelIdRef.current === nextModelId) return; + consumedInitialModelIdRef.current = nextModelId; + setModelId(nextModelId); + }, [initialModelId, preferencesReady]); const currentNativeControls = useMemo(() => ({ interactionMode, @@ -5355,7 +5382,7 @@ export function AgentChatPane({ cursorModeId: handoffCursorModeId, cursorConfigValues: handoffCursorConfigValues, }); - setHandoffOpen(false); + setChatActionsOpen(false); notifySessionCreated(result.session); void refreshSessions().catch(() => {}); } catch (handoffError) { @@ -6514,37 +6541,22 @@ export function AgentChatPane({ providerChatAccent(selectedSession?.provider ?? selectedModelDesc?.family ?? null) ?? selectedModelDesc?.color ?? "#A1A1AA"; + const chatActionsToolbarIcon = chatActionsOpen + ? (chatActionsTab === "proof" + ? Cube + : chatActionsTab === "handoff" + ? ArrowBendUpRight + : TreeStructure) + : TreeStructure; + const ChatActionsToolbarIcon = chatActionsToolbarIcon; const proofArtifactCount = computerUseSnapshot?.artifacts?.length ?? 0; const proofSessionId = selectedSessionId ?? ""; - const proofPanelContent = ( - <> -
- Artifacts - -
-
- refreshComputerUseSnapshot(selectedSessionId, { force: true })} - /> -
- - ); - const subagentPanelContent = selectedSubagentPaneAvailable ? ( + const agentsTabContent = selectedSubagentPaneAvailable ? ( { void interrupt(); } : undefined} variant="pane" - onClose={() => setSubagentPaneOpen(false)} onSelectSubagent={(selection) => { setSubagentView({ taskId: selection.taskId, @@ -6556,7 +6568,218 @@ export function AgentChatPane({ }} selectedTaskId={subagentView?.taskId ?? null} /> - ) : null; + ) : ( +
+

No subagents detected

+
+ ); + const proofTabContent = ( +
+ refreshComputerUseSnapshot(selectedSessionId, { force: true })} + /> +
+ ); + const handoffTabContent = canShowHandoff ? ( +
+
+
Start a sibling chat on another model
+
+ {handoffTargetProvider === "claude" + ? "ADE can fork Claude with full SDK history, or start a brief handoff that sends a compact summary." + : "ADE will create a new work chat, inject a handoff summary from this session, and route you into the new tab."} +
+ {laneId ? ( +
+ New session stays in this lane ({laneDisplayLabel}). +
+ ) : null} +
+
+ + +
+ {handoffTargetProvider ? ( +
+
Permission mode
+ {handoffTargetProvider === "claude" ? ( + + ) : null} + {handoffTargetProvider === "codex" ? ( +
+ + {modelSupportsFastMode(handoffTargetDescriptor) ? ( + + ) : null} + {handoffCodexPermissionPreset === "custom" ? ( +
Session uses a custom policy; select a standard preset to apply to the new chat.
+ ) : null} +
+ ) : null} + {handoffTargetProvider === "opencode" ? ( + + ) : null} + {handoffTargetProvider === "droid" ? ( + + ) : null} + {handoffTargetProvider === "cursor" ? ( + + ) : null} +
+ ) : null} +
+ {handoffTargetProvider === "claude" + ? "Fork keeps the complete Claude transcript through the SDK. Brief sends a summary as the first message." + : "Create opens the new work chat and sends the handoff summary as its first message."} +
+
+ {handoffTargetProvider === "claude" ? ( + <> + + + + ) : ( + + )} +
+ {handoffBlocked ? ( +
{handoffButtonTitle}
+ ) : null} +
+ ) : ( +
+

Handoff is not available for this chat.

+
+ ); + const chatActionsPanelContent = ( + setChatActionsOpen(false)} + agentsContent={agentsTabContent} + proofContent={proofTabContent} + handoffContent={handoffTabContent} + /> + ); const cursorCloudPanelContent = ( ); const shellHeader = ( -
+
{/* Single-row header: title + git toolbar + actions */} -
+
- + {resolvedTitle} + {showWorkspaceChrome && laneId ? ( + navigate(openLaneInLanesTabPath(laneId))} + aria-label={`Open ${chatHeaderLaneName} in Lanes tab`} + /> + ) : null} {showClaudeCacheTimer ? ( ) : null} @@ -6658,7 +6889,7 @@ export function AgentChatPane({ {showWorkspaceChrome && laneId ? : null} -
+
{laneToolsVisible && iosSimulatorAvailable ? ( { const next = !current; if (next) { - setProofDrawerOpen(false); + setChatActionsOpen(false); setAppControlOpen(false); setCursorCloudPaneOpen(false); - setSubagentPaneOpen(false); } return next; }); @@ -6724,9 +6954,8 @@ export function AgentChatPane({ setAppControlOpen((current) => { const next = !current; if (next) { - setProofDrawerOpen(false); + setChatActionsOpen(false); setIosSimulatorOpen(false); - setSubagentPaneOpen(false); } return next; }); @@ -6750,91 +6979,60 @@ export function AgentChatPane({ compact label="Run" align="end" - triggerStyle={{ height: 28, padding: "0 10px" }} + triggerStyle={{ height: 24, padding: "0 8px" }} /> ) : null} - {showWorkspaceChrome && laneId ? ( + {(showWorkspaceChrome && laneId) || canShowHandoff ? ( 0 ? `${proofArtifactCount} artifact${proofArtifactCount === 1 ? "" : "s"} available.` : undefined, + label: chatActionsOpen ? "Close chat actions" : "Open chat actions", + description: chatActionsOpen + ? "Hide agents, proof artifacts, and handoff controls." + : "Open agents, proof artifacts, and handoff controls for this chat.", + effect: [ + proofArtifactCount > 0 ? `${proofArtifactCount} artifact${proofArtifactCount === 1 ? "" : "s"}` : null, + selectedSubagentSnapshots.length > 0 + ? `${selectedSubagentSnapshots.length} subagent${selectedSubagentSnapshots.length === 1 ? "" : "s"}` + : null, + ].filter(Boolean).join(" · ") || undefined, }} > - - ) : null} - {selectedSubagentPaneAvailable ? ( - - - {handoffOpen ? ( -
-
-
Start a sibling chat on another model
-
- {handoffTargetProvider === "claude" - ? "ADE can fork Claude with full SDK history, or start a brief handoff that sends a compact summary." - : "ADE will create a new work chat, inject a handoff summary from this session, and route you into the new tab."} -
- {laneId ? ( -
- New session stays in this lane ({laneDisplayLabel}). -
- ) : null} -
-
- - -
- {handoffTargetProvider ? ( -
-
Permission mode
- {handoffTargetProvider === "claude" ? ( - - ) : null} - {handoffTargetProvider === "codex" ? ( -
- - {modelSupportsFastMode(handoffTargetDescriptor) ? ( - - ) : null} - {handoffCodexPermissionPreset === "custom" ? ( -
Session uses a custom policy; select a standard preset to apply to the new chat.
- ) : null} -
- ) : null} - {handoffTargetProvider === "opencode" ? ( - - ) : null} - {handoffTargetProvider === "droid" ? ( - - ) : null} - {handoffTargetProvider === "cursor" ? ( - - ) : null} -
- ) : null} -
- {handoffTargetProvider === "claude" - ? "Fork keeps the complete Claude transcript through the SDK. Brief sends a summary as the first message." - : "Create opens the new work chat and sends the handoff summary as its first message."} -
-
- - {handoffTargetProvider === "claude" ? ( - <> - - - - ) : ( - - )} -
-
- ) : null} -
- ) : null} {!lockedSingleSessionMode && selectedSessionId ? (
{rightPaneDivider} - {proofDrawerOpen ? renderRightPane(proofPanelContent) : null} - {effectiveSubagentPaneOpen && subagentPanelContent ? renderRightPane(subagentPanelContent) : null} + {chatActionsOpen ? renderRightPane(chatActionsPanelContent) : null} {effectiveIosSimulatorOpen ? renderRightPane(iosSimulatorPanelContent) : null} {effectiveAppControlOpen ? renderRightPane(appControlPanelContent) : null} {effectiveCursorCloudPaneOpen ? renderRightPane(cursorCloudPanelContent) : null} @@ -8084,7 +8067,7 @@ export function AgentChatPane({ >
> = [ + { + id: "agents", + label: "Agents", + icon: TreeStructure, + gradient: "radial-gradient(circle, rgba(251,191,36,0.38) 0%, transparent 70%)", + color: "#fbbf24", + }, + { + id: "proof", + label: "Proof", + icon: Cube, + gradient: "radial-gradient(circle, rgba(52,211,153,0.42) 0%, transparent 70%)", + color: "#34d399", + }, + { + id: "handoff", + label: "Handoff", + icon: ArrowBendUpRight, + gradient: "radial-gradient(circle, rgba(167,139,250,0.42) 0%, transparent 70%)", + color: "#a78bfa", + }, +]; + +export function ChatActionsDrawerPanel({ + tab, + onTabChange, + onClose, + agentsContent, + proofContent, + handoffContent, +}: { + tab: ChatActionsTab; + onTabChange: (tab: ChatActionsTab) => void; + onClose: () => void; + agentsContent: ReactNode; + proofContent: ReactNode; + handoffContent: ReactNode; +}) { + const body = tab === "agents" ? agentsContent : tab === "proof" ? proofContent : handoffContent; + + return ( +
+
+ + +
+
+ {body} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx index abd2d27e7..abf43becb 100644 --- a/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx @@ -184,14 +184,6 @@ const STATUS_PILL_TONE: Record = { error: "border-rose-400/30 bg-rose-500/10 text-rose-200/85", }; -const STATUS_DOT_TONE: Record = { - idle: "bg-muted-fg/45", - active: "bg-emerald-300", - warn: "bg-amber-300", - muted: "bg-muted-fg/40", - error: "bg-rose-300", -}; - function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -1494,10 +1486,6 @@ export function ChatBuiltInBrowserPanel({
- - - ADE - {browserTabs.map((tab) => { const active = tab.id === activeTabId; const label = tab.title ?? tab.url ?? "New tab"; diff --git a/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx b/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx index eb81f9d03..ae2c92a67 100644 --- a/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatComposerShell.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, ReactNode } from "react"; +import type { ReactNode } from "react"; import type { ChatSurfaceMode } from "../../../shared/types"; import { cn } from "../ui/cn"; @@ -13,17 +13,11 @@ function ensureOrchestratorComposerStyles(): void { const sheet = document.createElement("style"); sheet.id = orchestratorComposerStyleId; sheet.textContent = ` - @keyframes ade-orchestrator-composer-spin { - to { transform: rotate(360deg); } - } @keyframes ade-orchestrator-composer-pulse { 0%, 100% { opacity: 0.28; transform: scale(0.98); } 50% { opacity: 0.46; transform: scale(1.02); } } @media (prefers-reduced-motion: reduce) { - [data-chat-composer-orchestrator-effects] { - animation: none !important; - } [data-chat-composer-orchestrator-glow] { animation: none !important; } @@ -69,33 +63,16 @@ export function ChatComposerShell({ data-chat-composer-orchestrator-active={orchestratorActive ? "true" : undefined} > {orchestratorActive ? ( - <> -
-
- +
) : null}
diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx index 4684e5114..4f8ebf6ac 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx @@ -102,16 +102,6 @@ describe("ChatGitToolbar", () => { } }); - it("opens the current lane from the chat Git toolbar", async () => { - renderToolbar(); - - fireEvent.click(await screen.findByRole("button", { name: /UI audit lane/i })); - - expect(screen.getByTestId("location").textContent).toBe( - "/lanes?laneId=lane-1&focus=single", - ); - }); - it("opens the PR creation handoff when the current lane has no linked PR", async () => { renderToolbar(); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index afdb5629b..506488d2c 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -1,9 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { - GitBranch, - GitCommit, - ArrowUp, GitPullRequest, CircleNotch, CheckCircle, @@ -17,14 +14,7 @@ import { import { AnimatePresence, motion } from "motion/react"; import { cn } from "../ui/cn"; import type { DiffChanges, PrSummary, PrCheck } from "../../../shared/types"; -import { - beginLaneGitActionRuntime, - patchLaneGitActionRuntimeStateIfCurrent, - scheduleLaneGitActionRuntimeClear, - useLaneGitActionRuntimeState, -} from "../lanes/LaneGitActionsPane"; -import { getLaneAccent } from "../lanes/laneColorPalette"; -import { useAppStore } from "../../state/appStore"; +import { useLaneGitActionRuntimeState } from "../lanes/LaneGitActionsPane"; import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; // --------------------------------------------------------------------------- @@ -104,24 +94,6 @@ function summarizeChecks(checks: PrCheck[]): { passed: number; failed: number; r return { passed, failed, running, total: checks.length }; } -function LaneLogoMark({ color, size = 16 }: { color: string; size?: number }) { - return ( - - - - ); -} - // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -131,22 +103,8 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ }: ChatGitToolbarProps) { const navigate = useNavigate(); const runtime = useLaneGitActionRuntimeState(laneId); - const lane = useAppStore((s) => s.lanes.find((l) => l.id === laneId) ?? null); - const laneName = lane?.name ?? null; - const laneAccent = getLaneAccent(lane, 0); - - const openLaneInLanesTab = useCallback(() => { - const params = new URLSearchParams({ - laneId, - focus: "single", - }); - navigate(`/lanes?${params.toString()}`); - }, [laneId, navigate]); const [dirtyCount, setDirtyCount] = useState(0); - const [diffStats, setDiffStats] = useState<{ adds: number; dels: number; files: number } | null>(null); - const [commitOpen, setCommitOpen] = useState(false); - const [commitMsg, setCommitMsg] = useState(""); const [linkedPr, setLinkedPr] = useState(null); const [prMenuOpen, setPrMenuOpen] = useState(false); const [prChecks, setPrChecks] = useState(null); @@ -164,11 +122,6 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ window.ade.diff.getChanges({ laneId }), ]); setDirtyCount(dirtyFileCount(changes)); - const staged = changes.staged.length; - const unstaged = changes.unstaged.length; - const totalAdds = changes.staged.reduce((acc, f) => acc + (f.additions ?? 0), 0) + changes.unstaged.reduce((acc, f) => acc + (f.additions ?? 0), 0); - const totalDels = changes.staged.reduce((acc, f) => acc + (f.deletions ?? 0), 0) + changes.unstaged.reduce((acc, f) => acc + (f.deletions ?? 0), 0); - setDiffStats({ adds: totalAdds, dels: totalDels, files: staged + unstaged }); } catch { // best-effort } @@ -214,90 +167,6 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ return unsubscribe; }, [laneId, linkedPr, refreshPr]); - // ----------------------------------------------------------------------- - // Shared action wrapper — mirrors LaneGitActionsPane.runAction - // ----------------------------------------------------------------------- - - const runAction = useCallback( - async (actionName: string, fn: () => Promise) => { - const v = beginLaneGitActionRuntime(laneId, { - busyAction: actionName, - notice: null, - error: null, - }); - try { - await fn(); - patchLaneGitActionRuntimeStateIfCurrent(laneId, v, { - busyAction: null, - notice: `${actionName} completed`, - error: null, - }); - scheduleLaneGitActionRuntimeClear(laneId, v, 3_000, { notice: null }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - patchLaneGitActionRuntimeStateIfCurrent(laneId, v, { - busyAction: null, - notice: null, - error: `${actionName} failed: ${message}`, - }); - } - }, - [laneId], - ); - - // ----------------------------------------------------------------------- - // Actions - // ----------------------------------------------------------------------- - - const handleGenerateMessage = useCallback(async () => { - const v = beginLaneGitActionRuntime(laneId, { - busyAction: "Generating message", - notice: null, - error: null, - }); - try { - const result = await window.ade.git.generateCommitMessage({ laneId }); - setCommitMsg(result.message); - patchLaneGitActionRuntimeStateIfCurrent(laneId, v, { - busyAction: null, - notice: null, - error: null, - }); - } catch (err: unknown) { - patchLaneGitActionRuntimeStateIfCurrent(laneId, v, { - busyAction: null, - notice: null, - error: err instanceof Error ? err.message : "Failed to generate message", - }); - } - }, [laneId]); - - const handleCommit = useCallback(async () => { - const msg = commitMsg.trim(); - if (!msg) { - // Auto-generate message when empty - await handleGenerateMessage(); - return; - } - await runAction("Commit", async () => { - // Stage all unstaged changes before committing - const changes = await window.ade.diff.getChanges({ laneId }); - const unstagedPaths = changes.unstaged.map((f) => f.path); - if (unstagedPaths.length > 0) { - await window.ade.git.stageAll({ laneId, paths: unstagedPaths }); - } - await window.ade.git.commit({ laneId, message: msg }); - setCommitMsg(""); - setCommitOpen(false); - }); - }, [laneId, commitMsg, runAction, handleGenerateMessage]); - - const handlePush = useCallback(async () => { - await runAction("Push", async () => { - await window.ade.git.push({ laneId }); - }); - }, [laneId, runAction]); - const handlePr = useCallback(() => { if (linkedPr) { navigate(`/prs?tab=normal&prId=${encodeURIComponent(linkedPr.id)}`); @@ -374,19 +243,6 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ } }, [linkedPr]); - const handleCommitKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - void handleCommit(); - } else if (e.key === "Escape") { - setCommitOpen(false); - setCommitMsg(""); - } - }, - [handleCommit], - ); - const isBusy = Boolean(runtime.busyAction); // ----------------------------------------------------------------------- @@ -418,7 +274,6 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ }, [linkedPr, prMenuOpen]); // Slide-out panel that appears to the right of the PR badge when toggled. - // Mirrors the inline expansion pattern used by the commit input above. const prMenu = useMemo(() => { if (!linkedPr) return null; const summary = prChecks ? summarizeChecks(prChecks) : null; @@ -524,20 +379,6 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ return (
- {/* Lane name (navigates to lane detail) */} - {laneId ? ( - - ) : null} - {/* Dirty count badge */} {dirtyCount > 0 ? ( @@ -545,81 +386,6 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ ) : null} - {/* Commit button / inline input */} - - {commitOpen ? ( - - setCommitMsg(e.target.value)} - onKeyDown={handleCommitKeyDown} - placeholder="Commit message (empty = auto-generate)..." - className="h-[22px] w-[200px] rounded-full border border-white/[0.08] bg-white/[0.03] px-2 font-mono text-[10px] text-fg/70 placeholder:text-fg/25 outline-none focus:border-white/[0.14]" - disabled={isBusy} - /> - - - ) : ( - setCommitOpen(true)} - disabled={isBusy} - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - transition={{ duration: 0.1 }} - > - - Stage & Commit - {diffStats && diffStats.files > 0 ? ( - - +{diffStats.adds} - -{diffStats.dels} - {diffStats.files}f - - ) : null} - - )} - - - {/* Push */} - - {/* PR badge or create button. When the badge is open it expands into a slide-out with action buttons + live PR status preview. */} {prBadge ? ( @@ -667,4 +433,4 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ }); const btnBase = - "inline-flex items-center gap-1.5 rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-1 font-sans text-[10px] font-medium text-fg/50 transition-all hover:border-violet-400/15 hover:bg-violet-500/[0.04] hover:text-fg/80 disabled:pointer-events-none disabled:opacity-40"; + "inline-flex items-center gap-1 rounded-md border border-white/[0.06] bg-white/[0.02] px-2 py-0.5 font-sans text-[10px] font-medium text-fg/50 transition-all hover:border-violet-400/15 hover:bg-violet-500/[0.04] hover:text-fg/80 disabled:pointer-events-none disabled:opacity-40"; diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx index 58bfec5be..52d2ce5b4 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react"; -import { ArrowClockwise, ArrowSquareOut, ArrowsInSimple, ArrowsOutSimple, BracketsCurly, CheckCircle, Circle, Copy, CursorClick, Desktop, DeviceMobile, FileCode, ImageSquare, Lightning, Lock, MagnifyingGlassMinus, MagnifyingGlassPlus, Play, Power, Selection, SpinnerGap, TextT, WarningCircle, Wrench } from "@phosphor-icons/react"; +import { ArrowClockwise, ArrowSquareOut, ArrowsInSimple, ArrowsOutSimple, BracketsCurly, CheckCircle, Circle, Copy, CursorClick, Desktop, DeviceMobile, FileCode, ImageSquare, Lightning, Lock, MagnifyingGlassMinus, MagnifyingGlassPlus, Play, Power, Selection, SpinnerGap, WarningCircle, Wrench } from "@phosphor-icons/react"; import type { AgentChatFileRef, IosElementContextItem, @@ -3885,7 +3885,6 @@ export function ChatIosSimulatorPanel({ {!mediaExpanded ?
{mode === "interact" && !controlsOwnedElsewhere && !showSetupChecklist ? (
- {header ? ( -
+
{header}
) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx index cc6f3b697..ab06ee33f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx @@ -582,19 +582,20 @@ export const ChatTerminalToggle = memo(function ChatTerminalToggle({ open, onToggle, }: ChatTerminalToggleProps) { + const label = open ? "Close terminal" : "Open terminal"; return ( ); diff --git a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx index 0cf05739f..e9d120ea4 100644 --- a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx +++ b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx @@ -57,6 +57,7 @@ export type FilesExplorerProps = { onContextMenu: (event: FilesExplorerContextMenuEvent) => void; onRenamePath: (sourcePath: string, destinationPath: string) => Promise; onInlineRenameSettled: () => void; + compact?: boolean; }; function parentDirOfPath(filePath: string): string { @@ -130,6 +131,7 @@ export function FilesExplorer({ onContextMenu, onRenamePath, onInlineRenameSettled, + compact = false, }: FilesExplorerProps) { const scrollRef = useRef(null); const renameInputRef = useRef(null); @@ -207,6 +209,53 @@ export function FilesExplorer({ return (
+ {compact ? ( +
+ + + + + + + + + +
+ ) : ( + <>
@@ -248,6 +297,7 @@ export function FilesExplorer({
+ + )} {renameError ? (
diff --git a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx index 2f07b48ab..b453fdfd9 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx @@ -631,6 +631,45 @@ describe("FilesPage", () => { expect(mergeEditor?.value).toContain("<<<<<<< HEAD"); }); + it("hides the workspace selector chrome when embedded in the Work sidebar", async () => { + const laneId = "lane-work-chat"; + useAppStore.setState({ + selectedLaneId: laneId, + lanes: [{ id: laneId, name: "Work chat lane", branchRef: "refs/heads/feat/work-chat" }] as any, + }); + vi.mocked(window.ade.files.listWorkspaces).mockResolvedValue([ + { + id: "primary", + kind: "primary", + laneId: null, + name: "ADE", + branchRef: "refs/heads/main", + rootPath: projectRoot, + isReadOnlyByDefault: false, + }, + { + id: "lane-ws", + kind: "worktree", + laneId, + name: "Work chat lane", + branchRef: "refs/heads/feat/work-chat", + rootPath: `${projectRoot}/.ade/worktrees/work-chat`, + isReadOnlyByDefault: false, + }, + ]); + + renderFilesPage(undefined, { embedded: true, preferredLaneId: laneId }); + + await waitFor(() => { + expect(window.ade.files.listTree).toHaveBeenCalledWith(expect.objectContaining({ + workspaceId: "lane-ws", + })); + }); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.queryByTestId("files.header")).toBeNull(); + expect(screen.getByText("EXPLORER")).toBeTruthy(); + }); + it("remaps clean open tabs when files are renamed", async () => { renderFilesPage({ openFilePath: "src/index.ts", diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 4f0231e3b..be2dd7c2b 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -1923,10 +1923,11 @@ export function FilesPage({ explorer: { title: "Explorer", icon: FolderOpen, - meta: activeWorkspace?.name, + meta: embedded ? undefined : activeWorkspace?.name, bodyClassName: "flex min-h-0 flex-col overflow-hidden", children: ( {/* Mode toggle group */} -
+
{(["edit", "diff", "conflict"] as const).map((m) => { - const label = m === "edit" ? "CODE" : m === "diff" ? "CHANGES" : "MERGE"; + const label = m === "edit" ? (embedded ? "CODE" : "CODE") : m === "diff" ? (embedded ? "DIFF" : "CHANGES") : (embedded ? "MERGE" : "MERGE"); const description = m === "edit" ? "View and edit the file content." : m === "diff" ? "View the diff between working changes and the last commit." : "View and resolve merge conflicts."; const isActive = mode === m; const disabled = m === "diff" ? (!laneIdForDiff || !activeTabPath) : m === "conflict" ? !activeTabPath : false; return ( - +
@@ -2226,28 +2228,28 @@ export function FilesPage({ const Icon = config.icon; return (
-
- {Icon ? : null} - +
+ {Icon ? : null} + {config.title.toUpperCase()} {config.meta ? ( @@ -2260,7 +2262,7 @@ export function FilesPage({ ) : null}
-
+
{config.headerActions}
@@ -2269,35 +2271,34 @@ export function FilesPage({
); - }, [paneConfigs]); + }, [paneConfigs, embedded]); return (
- {/* Header bar */} + {/* Header bar — full Files tab only; Work sidebar uses the active session lane via preferredLaneId */} + {!embedded ? (
{/* Numbered title group */} - {embedded ? null : ( -
- 03 - - FILES - {workspaces.length} WS -
- )} +
+ 03 + + FILES + {workspaces.length} WS +
{/* Workspace selector */}
@@ -2306,28 +2307,27 @@ export function FilesPage({ title={activeWorkspaceSelectTitle} onChange={(e) => switchWorkspace(e.target.value)} style={{ - height: embedded ? 24 : 32, - padding: embedded ? "0 8px" : "0 12px", - fontSize: embedded ? 11 : 12, + height: 32, + padding: "0 12px", + fontSize: 12, fontFamily: MONO_FONT, fontWeight: 600, color: COLORS.success, background: COLORS.recessedBg, borderRadius: 8, border: `1px solid ${COLORS.outlineBorder}`, cursor: "pointer", outline: "none", minWidth: 0, - maxWidth: embedded ? "100%" : 280, + maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", }} onFocus={(e) => { e.currentTarget.style.borderColor = COLORS.accent; }} onBlur={(e) => { e.currentTarget.style.borderColor = COLORS.outlineBorder; }} > - {workspaceSelectOptions.map((opt) => ( - - ))} + {workspaceSelectOptions.map((opt) => ( + + ))} - {embedded ? null : ( - <> - + { e.currentTarget.style.borderColor = COLORS.accent; e.currentTarget.style.color = COLORS.accent; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = COLORS.outlineBorder; e.currentTarget.style.color = COLORS.textSecondary; }} > - View lane - - - - )} + View lane + + +
{/* Read-only badge */} @@ -2369,7 +2368,7 @@ export function FilesPage({ ) : null} {/* Trust / edit toggle */} - {!embedded && activeWorkspace?.isReadOnlyByDefault ? ( + {activeWorkspace?.isReadOnlyByDefault ? (
+ ) : null} {/* Warning banners */} - {(activeWorkspace?.isReadOnlyByDefault && !allowPrimaryEdit) || (activeWorkspace?.kind === "primary" && suggestedLaneWorkspace) ? ( + {!embedded && ((activeWorkspace?.isReadOnlyByDefault && !allowPrimaryEdit) || (activeWorkspace?.kind === "primary" && suggestedLaneWorkspace)) ? (
-
+
+
{renderPane("explorer")} diff --git a/apps/desktop/src/renderer/components/history/HistoryPage.tsx b/apps/desktop/src/renderer/components/history/HistoryPage.tsx index 4b224fb45..abae5530b 100644 --- a/apps/desktop/src/renderer/components/history/HistoryPage.tsx +++ b/apps/desktop/src/renderer/components/history/HistoryPage.tsx @@ -1,6 +1,7 @@ import React, { Suspense, useEffect, useCallback, useRef, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { Clock, GitBranch } from "@phosphor-icons/react"; +import { Clock } from "@phosphor-icons/react"; +import { LaneIcon } from "../ui/vcsIcons"; import { useAppStore } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; import { @@ -557,7 +558,7 @@ function HistoryPageContent({ active = true }: { active?: boolean } = {}) { /> {surface === "commits" ? (
- + Lane diff --git a/apps/desktop/src/renderer/components/lanes/BranchPickerView.tsx b/apps/desktop/src/renderer/components/lanes/BranchPickerView.tsx index ab5c74597..d7a864d7f 100644 --- a/apps/desktop/src/renderer/components/lanes/BranchPickerView.tsx +++ b/apps/desktop/src/renderer/components/lanes/BranchPickerView.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { ArrowLeft, GitBranch, GitCommit, MagnifyingGlass, Tag } from "@phosphor-icons/react"; +import { ArrowLeft, GitCommit, MagnifyingGlass, Tag } from "@phosphor-icons/react"; +import { BranchIcon } from "../ui/vcsIcons"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { BranchPullRequest } from "../../../shared/types"; import type { LaneBranchOption } from "./laneUtils"; @@ -73,7 +74,7 @@ function BranchRow({ data-branch-name={branch.name} > - + diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index c3ecbec39..cb094ed3b 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -5,6 +5,7 @@ import { useAppStore } from "../../state/appStore"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { modifierKeyLabel } from "../../lib/platform"; import { cn } from "../ui/cn"; +import { BranchIcon } from "../ui/vcsIcons"; import { SmartTooltip, type SmartTooltipContent } from "../ui/SmartTooltip"; import { COLORS, LABEL_STYLE, MONO_FONT, inlineBadge, outlineButton, primaryButton, dangerButton } from "./laneDesignTokens"; import { CommitTimeline } from "./CommitTimeline"; @@ -1661,6 +1662,9 @@ export function LaneGitActionsPane({ + {lane.branchRef} 05 - + LANES {filteredLanes.length}
@@ -2892,7 +2893,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { aria-disabled={!canSwitchBranchLane} title={branchLaneSwitchDisabledReason ?? undefined} > - + {branchLane.branchRef} diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index 9ce0d6d26..4395143fe 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from "react"; import { ArrowSquareOut, - GitBranch, WarningCircle, Archive, Trash, @@ -17,6 +16,7 @@ import { TreeStructure, } from "@phosphor-icons/react"; import { Button } from "../ui/Button"; +import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; import type { LaneDeleteProgress, LaneDeleteRisk, @@ -55,8 +55,9 @@ function ManageLaneHeaderDetails({ lanes, isBatch }: { lanes: LaneSummary[]; isB
{lanes.map((lane) => (
- + {lane.name} + {lane.branchRef} {lane.laneType} @@ -79,7 +80,7 @@ function ManageLaneHeaderDetails({ lanes, isBatch }: { lanes: LaneSummary[]; isB return (
- + {lane.name} {lane.status.dirty ? ( @@ -349,7 +350,7 @@ export function ManageLaneDialog({ open={open} onOpenChange={onOpenChange} title={isBatch ? `Manage ${lanes.length} Lanes` : "Manage Lane"} - icon={GitBranch} + icon={LaneIcon} headerExtra={headerExtra} widthClassName="w-[calc(100vw-1rem)] max-w-[720px] sm:max-w-[min(720px,calc(100vw-2rem))]" busy={laneActionBusy} @@ -580,7 +581,7 @@ function buildDeleteRemovalPreview( ? ` · ${risk.unpushedCommitCount} unpushed` : ""; items.push({ - icon: , + icon: , label: `Local branch · ${branchLabel}${unpushed}`, }); } @@ -588,7 +589,7 @@ function buildDeleteRemovalPreview( if (deleteMode === "remote_branch" && branchLabel) { const remote = remoteName.trim() || "origin"; items.push({ - icon: , + icon: , label: `Remote · ${remote}/${branchLabel}`, hint: risk && !risk.remoteBranchExists ? "Not on remote yet" : undefined, }); diff --git a/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts b/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts index 120c99131..3d54722ca 100644 --- a/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts +++ b/apps/desktop/src/renderer/components/lanes/laneColorPalette.ts @@ -1,49 +1,26 @@ import type { LaneSummary } from "../../../shared/types"; - -export type LaneColor = { - hex: string; - name: string; +import { + LANE_CLASSIC_COLORS, + LANE_CLASSIC_COUNT, + LANE_COLOR_PALETTE, + LANE_FALLBACK_COLORS, + LANE_RAINBOW_COLORS, + allocateLaneColor, + laneColorName, + nextAvailableLaneColor, + type LaneColor, +} from "../../../shared/laneColorPalette"; + +export { + LANE_CLASSIC_COLORS, + LANE_CLASSIC_COUNT, + LANE_COLOR_PALETTE, + LANE_FALLBACK_COLORS, + LANE_RAINBOW_COLORS, + laneColorName, + type LaneColor, }; -// The first 8 classic colors are also the legacy LANE_ACCENT_COLORS fallback -// used as an index-based accent for unassigned lanes. -export const LANE_CLASSIC_COLORS: readonly LaneColor[] = [ - { hex: "#a78bfa", name: "Violet" }, - { hex: "#60a5fa", name: "Blue" }, - { hex: "#34d399", name: "Emerald" }, - { hex: "#fbbf24", name: "Amber" }, - { hex: "#f472b6", name: "Pink" }, - { hex: "#fb923c", name: "Orange" }, - { hex: "#2dd4bf", name: "Teal" }, - { hex: "#c084fc", name: "Purple" }, - { hex: "#f87171", name: "Red" }, - { hex: "#a3e635", name: "Lime" }, - { hex: "#22d3ee", name: "Cyan" }, - { hex: "#e879f9", name: "Fuchsia" }, -]; - -// Pure rainbow primaries R O Y G B I V. -export const LANE_RAINBOW_COLORS: readonly LaneColor[] = [ - { hex: "#ef4444", name: "Bright Red" }, - { hex: "#f97316", name: "Bright Orange" }, - { hex: "#facc15", name: "Bright Yellow" }, - { hex: "#22c55e", name: "Bright Green" }, - { hex: "#2563eb", name: "Bright Blue" }, - { hex: "#4f46e5", name: "Indigo" }, - { hex: "#7c3aed", name: "Bright Violet" }, -] as const; - -export const LANE_COLOR_PALETTE: readonly LaneColor[] = [ - ...LANE_CLASSIC_COLORS, - ...LANE_RAINBOW_COLORS, -] as const; - -export const LANE_CLASSIC_COUNT = LANE_CLASSIC_COLORS.length; - -export const LANE_FALLBACK_COLORS: readonly string[] = LANE_COLOR_PALETTE - .slice(0, 8) - .map((c) => c.hex); - export function getLaneAccent(lane: Pick | null | undefined, fallbackIndex: number): string { if (lane?.color) return lane.color; return LANE_FALLBACK_COLORS[fallbackIndex % LANE_FALLBACK_COLORS.length]; @@ -60,15 +37,7 @@ export function colorsInUse(lanes: readonly LaneSummary[], excludeLaneId?: strin } export function nextAvailableColor(lanes: readonly LaneSummary[]): string | null { - const used = colorsInUse(lanes); - for (const entry of LANE_COLOR_PALETTE) { - if (!used.has(entry.hex.toLowerCase())) return entry.hex; - } - return null; + return nextAvailableLaneColor(colorsInUse(lanes)); } -export function laneColorName(hex: string | null | undefined): string | null { - if (!hex) return null; - const lower = hex.toLowerCase(); - return LANE_COLOR_PALETTE.find((c) => c.hex.toLowerCase() === lower)?.name ?? null; -} +export { allocateLaneColor }; diff --git a/apps/desktop/src/renderer/components/lanes/linearProjectIcon.tsx b/apps/desktop/src/renderer/components/lanes/linearProjectIcon.tsx new file mode 100644 index 000000000..f66eaa231 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/linearProjectIcon.tsx @@ -0,0 +1,154 @@ +import { LINEAR_BRAND } from "./linearBrand"; + +const LINEAR_ICON_ALIASES: Record = { + robot_face: "🤖", + robot: "🤖", + file_folder: "📁", + folder: "📁", + clock3: "🕒", + clock: "🕒", + history: "🕘", + link: "🔗", + chart: "📊", + graph: "📈", + rocket: "🚀", + bug: "🐛", + hammer: "🔨", + wrench: "🔧", + gear: "⚙️", + lightning: "⚡", + star: "⭐", + fire: "🔥", + calendar: "📅", + bookmark: "🔖", + book: "📖", + code: "💻", + terminal: "🖥️", + mobile: "📱", + globe: "🌍", + lock: "🔒", + search: "🔍", + bell: "🔔", + mail: "✉️", + chat: "💬", + users: "👥", + user: "👤", + target: "🎯", + flag: "🚩", + ship: "🚢", + package: "📦", + inbox: "📥", + trash: "🗑️", + paint: "🎨", + camera: "📷", + video: "🎬", + game: "🎮", + trophy: "🏆", + gift: "🎁", + bulb: "💡", + brain: "🧠", + puzzle: "🧩", + shield: "🛡️", + cloud: "☁️", + database: "🗄️", + sync: "🔄", + refresh: "🔄", + home: "🏠", + building: "🏢", + cart: "🛒", + money: "💰", + doc: "📄", + note: "📝", + pencil: "✏️", + drive: "💾", + lab: "🧪", + leaf: "🌿", + plane: "✈️", + coffee: "☕", + settings: "⚙️", + work: "💼", + mission: "🎯", + missions: "🎯", + lane: "🛣️", + lanes: "🛣️", + preview: "👁️", + test: "🧪", + tui: "🖥️", + run: "▶️", + pr: "🔀", + prs: "🔀", + review: "👀", +}; + +function projectIconBackground(color: string | null | undefined): string { + const normalized = color?.trim(); + if (normalized && /^#[0-9a-f]{6}$/i.test(normalized)) return `${normalized}30`; + if (normalized && /^#[0-9a-f]{3}$/i.test(normalized)) { + const [r, g, b] = normalized.slice(1).split(""); + return `#${r}${r}${g}${g}${b}${b}30`; + } + return "rgba(255,255,255,0.08)"; +} + +export function resolveLinearProjectIcon(icon: string | null | undefined): string | null { + const trimmed = icon?.trim(); + if (!trimmed) return null; + + const shortcodeMatch = /^:([a-z0-9_+-]+):$/i.exec(trimmed); + if (shortcodeMatch) { + return LINEAR_ICON_ALIASES[shortcodeMatch[1].toLowerCase()] ?? null; + } + + // GraphQL returns raw emoji; SDK may return short names for unknown presets. + if (!/^[a-z0-9_-]+$/i.test(trimmed)) { + return trimmed; + } + + return LINEAR_ICON_ALIASES[trimmed.toLowerCase()] ?? null; +} + +export function LinearProjectIcon({ + icon, + color, + name, + size = 16, +}: { + icon: string | null | undefined; + color: string | null | undefined; + name: string; + size?: number; +}) { + const glyph = resolveLinearProjectIcon(icon); + const background = projectIconBackground(color); + + if (glyph) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx b/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx index e62a29f15..e3a9f07bb 100644 --- a/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx +++ b/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx @@ -19,7 +19,7 @@ const FULL_TUTORIAL_ID = "first-journey"; type MenuPosition = { top: number; right: number } | null; -export function HelpMenu() { +export function HelpMenu({ compact = false }: { compact?: boolean }) { const navigate = useNavigate(); const smartTooltipsEnabled = useAppStore((s) => s.smartTooltipsEnabled); const setSmartTooltipsEnabled = useAppStore((s) => s.setSmartTooltipsEnabled); @@ -127,8 +127,11 @@ export function HelpMenu() { aria-expanded={open} title="Help · tours, glossary, and preferences" className={cn( - "ade-shell-control inline-flex h-[24px] w-[24px] items-center justify-center", - "transition-[background-color,color,border-color,box-shadow] duration-150" + "ade-shell-control inline-flex items-center justify-center", + compact + ? "ade-shell-header-utility-btn" + : "h-[24px] w-[24px]", + "transition-[background-color,color,border-color,box-shadow] duration-150", )} onClick={() => (open ? close() : openAt())} style={{ @@ -136,7 +139,7 @@ export function HelpMenu() { color: open ? "var(--color-accent)" : undefined, } as React.CSSProperties} > - + {open && position diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index 0070c99e4..899cebed6 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -1,7 +1,8 @@ import React from "react"; import { useNavigate } from "react-router-dom"; import * as Dialog from "@radix-ui/react-dialog"; -import { GitPullRequest, GitMerge, Stack as Layers, CheckCircle, Warning, CircleNotch, X, GitBranch, Sparkle, ArrowRight, ArrowLeft, Check, DotsSixVertical, Trash, ArrowUp, ArrowDown } from "@phosphor-icons/react"; +import { GitPullRequest, GitMerge, Stack as Layers, CheckCircle, Warning, CircleNotch, X, Sparkle, ArrowRight, ArrowLeft, Check, DotsSixVertical, Trash, ArrowUp, ArrowDown } from "@phosphor-icons/react"; +import { BranchIcon } from "../ui/vcsIcons"; import { useAppStore } from "../../state/appStore"; import type { MergeMethod, @@ -1297,7 +1298,7 @@ export function CreatePrModal({
SOURCE BRANCH
- TARGET BRANCH
- TARGET BRANCH
- TARGET BRANCH
- {pr.repoOwner}/{pr.repoName} | - + {pr.headBranch} + {pr.baseBranch}
@@ -2168,7 +2170,7 @@ export function PrDetailPane({ ) : null} {onShowInGraph ? ( ) : null} )} @@ -1676,7 +1676,7 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) {
{selectedRun ? (
- + {selectedRunScopeVisual ? : null} diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx index e07ca0eec..b4a6039a4 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx @@ -1,7 +1,7 @@ import React from "react"; import { ChatMarkdown } from "../chat/chatMarkdown"; import { buildChatAppearanceRootStyle } from "../chat/chatAppearance"; -import { ChatSurfaceShell } from "../chat/ChatSurfaceShell"; +import { CHAT_SHELL_HEADER_CLASS, ChatSurfaceShell } from "../chat/ChatSurfaceShell"; import { providerChatAccent } from "../chat/chatSurfaceTheme"; import { ChatWorkLogBlock } from "../chat/ChatWorkLogBlock"; import type { ChatWorkLogEntry } from "../chat/chatTranscriptRows"; @@ -140,7 +140,7 @@ function PreviewUsageRow({ provider }: { provider: PreviewProviderKey }) { function PreviewShellHeader({ provider }: { provider: PreviewProviderKey }) { const { name, Logo } = PREVIEW_PROVIDER_META[provider]; return ( -
+
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 2e7bc11ef..3ff22e3a1 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -352,7 +352,7 @@ const ModelPickerTrigger = memo( className={cn( "inline-flex min-w-0 items-center gap-1.5 rounded-md border font-sans transition-colors duration-150", compact - ? "h-7 px-1.5 text-[10px]" + ? "h-6 px-1 text-[9px]" : "h-8 px-2 text-[11px] sm:text-[12px]", "border-white/[0.06] bg-white/[0.03] text-fg/80", "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", @@ -407,15 +407,17 @@ const FastModeButton = memo(function FastModeButton({ disabled={disabled || !onToggle} onClick={() => onToggle?.(!active)} className={cn( - "inline-flex shrink-0 items-center gap-1 rounded-md border font-sans font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45", - compact ? "h-7 px-1.5 text-[10px]" : "h-8 px-2 text-[11px]", + "inline-flex shrink-0 items-center justify-center rounded-md border font-sans font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45", + compact + ? "ade-chat-composer-fast-toggle h-6 gap-0.5 px-1.5 text-[9px]" + : "h-8 gap-1 px-2 text-[11px]", active ? "border-amber-300/30 bg-amber-400/12 text-amber-100 shadow-[0_0_0_1px_rgba(251,191,36,0.08)]" : "border-white/[0.07] bg-white/[0.025] text-muted-fg/60 hover:bg-white/[0.06] hover:text-fg/80", )} > - - Fast + + Fast ); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx index 32f01b1ae..9c05e4610 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx @@ -186,7 +186,7 @@ const ReasoningEffortTrigger = memo( className={cn( "inline-flex min-w-0 items-center gap-1 rounded-md border font-sans transition-colors duration-150", compact - ? "h-7 px-1.5 text-[10px]" + ? "h-6 px-1 text-[9px]" : "h-8 px-2 text-[11px] sm:text-[12px]", "border-white/[0.06] bg-white/[0.03] text-fg/80", "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", diff --git a/apps/desktop/src/renderer/components/terminals/LaneChip.test.tsx b/apps/desktop/src/renderer/components/terminals/LaneChip.test.tsx new file mode 100644 index 000000000..9a9d69038 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LaneChip.test.tsx @@ -0,0 +1,46 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { LaneChip } from "./LaneChip"; +import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; + +function LocationProbe() { + const location = useLocation(); + return
{`${location.pathname}${location.search}`}
; +} + +function Harness() { + const navigate = useNavigate(); + return ( + <> + navigate(openLaneInLanesTabPath("lane-1"))} + aria-label="Open UI audit lane in Lanes tab" + /> + + + ); +} + +describe("LaneChip", () => { + afterEach(() => { + cleanup(); + }); + + it("opens the lane in the Lanes tab when clicked", () => { + render( + + + } /> + + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Open UI audit lane in Lanes tab/i })); + expect(screen.getByTestId("location").textContent).toBe("/lanes?laneId=lane-1&focus=single"); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/LaneChip.tsx b/apps/desktop/src/renderer/components/terminals/LaneChip.tsx index 95107bb21..148e449c1 100644 --- a/apps/desktop/src/renderer/components/terminals/LaneChip.tsx +++ b/apps/desktop/src/renderer/components/terminals/LaneChip.tsx @@ -1,10 +1,29 @@ -import type { ButtonHTMLAttributes, HTMLAttributes } from "react"; +import type { ButtonHTMLAttributes, HTMLAttributes, MouseEvent } from "react"; import { cn } from "../ui/cn"; +import { LaneIcon, BranchIcon } from "../ui/vcsIcons"; + +const DEFAULT_LANE_COLOR = "#ffffff"; + +export function laneDisplayColor(laneColor?: string | null): string { + const trimmed = laneColor?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_LANE_COLOR; +} + +export function LaneLogoMark({ + color, + size = 10, +}: { + color: string; + size?: number; +}) { + return ; +} export type LaneChipProps = { laneName: string; laneColor?: string | null; maxWidth?: number; + compact?: boolean; className?: string; onClick?: () => void; } & Omit, "onClick" | "children">; @@ -13,37 +32,31 @@ export function LaneChip({ laneName, laneColor, maxWidth = 140, + compact = false, className, onClick, style, ...rest }: LaneChipProps) { - const hasColor = laneColor != null && String(laneColor).trim() !== ""; - const color = hasColor ? String(laneColor).trim() : null; - const background = color - ? `color-mix(in srgb, ${color} 85%, rgba(10,10,12,0.4))` - : "color-mix(in srgb, var(--color-fg) 6%, transparent)"; - const border = color - ? `1px solid color-mix(in srgb, ${color} 55%, rgba(255,255,255,0.08))` - : "1px solid color-mix(in srgb, var(--color-border) 70%, transparent)"; - const textColor = color ? "rgba(255,255,255,0.96)" : "var(--color-fg)"; + const color = laneDisplayColor(laneColor); const chipClassName = cn( - "inline-flex items-center text-[11px] font-medium leading-none rounded-full select-none transition-opacity", - onClick ? "cursor-pointer hover:opacity-85" : null, + "inline-flex min-w-0 items-center gap-1 font-medium leading-none select-none", + compact ? "text-[9px]" : "text-[10px]", + onClick ? "cursor-pointer transition-opacity hover:opacity-80" : null, className, ); const chipStyle = { maxWidth, - padding: "3px 8px", - background, - border, - color: textColor, + color, ...style, }; const label = ( - - {laneName} - + <> + + + {laneName} + + ); if (onClick) { @@ -72,3 +85,98 @@ export function LaneChip({ ); } + +export function SessionLaneHeaderLabel({ + sessionTitle, + laneName, + laneColor, + branchLabel, + laneMaxWidth = 120, + sessionTitleClassName, + onSessionTitleClick, + onLaneClick, + onLaneContextMenu, +}: { + sessionTitle: string; + laneName: string; + laneColor?: string | null; + /** Git branch label (e.g. from `branchNameFromRef`); truncates before lane/session title. */ + branchLabel?: string | null; + laneMaxWidth?: number; + sessionTitleClassName?: string; + /** Opens this session in the Work tabs view. */ + onSessionTitleClick?: () => void; + onLaneClick?: () => void; + onLaneContextMenu?: (event: MouseEvent) => void; +}) { + const color = laneDisplayColor(laneColor); + const branchText = branchLabel?.trim() ?? ""; + const sessionTitleClass = cn( + "ade-session-lane-header-title min-w-0 shrink-0 truncate text-[13px] font-bold leading-none text-fg/90", + onSessionTitleClick ? "cursor-pointer transition-opacity hover:opacity-80" : undefined, + sessionTitleClassName, + ); + const sessionTitleNode = onSessionTitleClick ? ( + + ) : ( + + {sessionTitle} + + ); + + return ( +
+ {sessionTitleNode} + {onLaneClick ? ( + + ) : ( + + + {laneName} + + )} + {branchText ? ( + + + {branchText} + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx b/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx index 6df9eb915..1a0cc3480 100644 --- a/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx +++ b/apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx @@ -1,10 +1,15 @@ import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { CaretUpDown, Check, GitBranch, MagnifyingGlass } from "@phosphor-icons/react"; +import { CaretUpDown, Check, MagnifyingGlass } from "@phosphor-icons/react"; +import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; +import { LaneLogoMark, laneDisplayColor } from "./LaneChip"; import { branchNameFromRef } from "../prs/shared/laneBranchTargets"; import { COLORS, laneSurfaceTint } from "../lanes/laneDesignTokens"; +/** Synthetic lane id for the draft-composer “auto-create lane” row. */ +export const AUTO_CREATE_LANE_OPTION_ID = "__ade_auto_create_lane__"; + /** `LaneSummary` is assignable; callers may also pass a minimal `{ id, name, color? }` without `branchRef`. */ export type LaneComboboxLane = { id: string; @@ -21,11 +26,25 @@ type LaneListItem = { branchLabel: string | null; }; +const POPOVER_GAP = 4; +const VIEWPORT_PAD = 10; +const POPOVER_PREFERRED_MAX_HEIGHT = 320; +const POPOVER_MIN_HEIGHT = 160; + function resolveBranchLabel(ref: string | null | undefined): string | null { if (!ref) return null; return branchNameFromRef(ref) || null; } +function laneListIcon(item: LaneListItem) { + const color = item.color ? laneDisplayColor(item.color) : "var(--color-muted-fg)"; + return item.color ? ( + + ) : ( + + ); +} + type LaneComboboxProps = { lanes: LaneComboboxLane[]; value: string; @@ -129,11 +148,14 @@ export function LaneCombobox({ return () => document.removeEventListener("mousedown", handler); }, [open, close]); - // Focus search on open + // Focus search on open and sync keyboard highlight to the current selection. useEffect(() => { - if (open) { - requestAnimationFrame(() => searchInputRef.current?.focus()); - } + if (!open) return; + requestAnimationFrame(() => searchInputRef.current?.focus()); + const selectedIdx = items.findIndex((item) => item.id === value); + setHighlightedIndex(selectedIdx >= 0 ? selectedIdx : 0); + // Only re-sync when the menu opens — search filtering keeps its own highlight reset. + // eslint-disable-next-line react-hooks/exhaustive-deps -- open gate }, [open]); const handleKeyDown = useCallback( @@ -177,14 +199,33 @@ export function LaneCombobox({ const updatePosition = useCallback(() => { if (!triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.bottom; - const openAbove = spaceBelow < 200 && rect.top > 200; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_PAD; + const spaceAbove = rect.top - VIEWPORT_PAD; + const openAbove = spaceBelow < spaceAbove; + + const available = (openAbove ? spaceAbove : spaceBelow) - POPOVER_GAP; + const maxHeight = Math.max( + POPOVER_MIN_HEIGHT, + Math.min(POPOVER_PREFERRED_MAX_HEIGHT, available), + ); + + const width = Math.min(280, Math.max(rect.width, 260)); + let left = rect.left; + if (left + width > viewportWidth - VIEWPORT_PAD) { + left = viewportWidth - width - VIEWPORT_PAD; + } + left = Math.max(VIEWPORT_PAD, left); + setPopoverStyle({ - left: rect.left, - width: Math.max(rect.width, 260), + left, + width, + maxHeight, ...(openAbove - ? { bottom: window.innerHeight - rect.top + 4 } - : { top: rect.bottom + 4 }), + ? { bottom: viewportHeight - rect.top + POPOVER_GAP } + : { top: rect.bottom + POPOVER_GAP }), }); }, []); useEffect(() => { @@ -287,10 +328,7 @@ export function LaneCombobox({ style={triggerStyle} > {displayColor ? ( - + ) : null} {selectedBranchLabel ? (
- -
+
setSearch(e.target.value)} - style={{ borderBottom: "none", paddingLeft: 6 }} />
-
{items.length === 0 ? (
) : ( - items.map((item, idx) => { + items.map((item) => { const isSelected = item.id === value; - const isHighlighted = idx === highlightedIndex; - const dot = item.color ? ( - - ) : ( - - ); + const isAutoCreate = item.id === AUTO_CREATE_LANE_OPTION_ID; + + if (isAutoCreate) { + return ( + + ); + } + const titleRow = (
- {dot} + {laneListIcon(item)} {item.name} @@ -436,9 +482,7 @@ export function LaneCombobox({ type="button" className="ade-lane-popover-item" data-selected={isSelected ? "true" : undefined} - data-highlighted={isHighlighted ? "true" : undefined} onClick={() => selectItem(item.id)} - onMouseEnter={() => setHighlightedIndex(idx)} style={ item.branchLabel ? { @@ -454,31 +498,13 @@ export function LaneCombobox({ {item.branchLabel ? ( <> {titleRow} -
- + - + {item.branchLabel}
diff --git a/apps/desktop/src/renderer/components/terminals/OrchestratorComposerEntry.test.tsx b/apps/desktop/src/renderer/components/terminals/OrchestratorComposerEntry.test.tsx index 61b39d429..64b855785 100644 --- a/apps/desktop/src/renderer/components/terminals/OrchestratorComposerEntry.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/OrchestratorComposerEntry.test.tsx @@ -1,142 +1,16 @@ /* @vitest-environment jsdom */ /** - * Composer + sidebar entry points for new orchestrator chats. The user - * can pick "Orchestrator" from either: - * 1. The SessionListPane "+ New Chat" picker (purple-accent pill). - * 2. The AgentChatComposer prompt-box mode button. + * Composer entry point for new orchestrator chats. The user picks + * "Orchestrator" from the AgentChatComposer prompt-box mode button. * - * Both flows resolve to `onShowDraftKind("chat-orchestrator")`. The + * That flow resolves to `onShowDraftKind("chat-orchestrator")`. The * subsequent draft submit calls `agentChat.create({ interactionMode: * "orchestrator-lead", ... })` and `orchestration.runCreate({ laneId, * leadSessionId })` — see `goal.md` §10.1 + §17 step 6. */ -import { fireEvent, render, screen, within } from "@testing-library/react"; -import type { ComponentProps } from "react"; -import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; -import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; -import { SessionListPane } from "./SessionListPane"; - -vi.mock("./useSessionDelta", () => ({ - useSessionDelta: () => null, -})); - -vi.mock("./ToolLogos", () => ({ - ToolLogo: () => , -})); - -function makeLane(overrides: Partial = {}): LaneSummary { - return { - id: "lane-known", - name: "Known Lane", - laneType: "worktree", - baseRef: "main", - branchRef: "known-lane", - worktreePath: "/tmp/known-lane", - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - createdAt: "2026-04-22T10:00:00.000Z", - ...overrides, - }; -} - -function makeSession(overrides: Partial = {}): TerminalSessionSummary { - return { - id: "session-mobile", - laneId: "lane-mobile", - laneName: "Mobile-created lane", - ptyId: null, - tracked: true, - pinned: false, - manuallyNamed: false, - goal: null, - toolType: "codex-chat", - title: "Mobile Tool Streaming UI", - status: "running", - startedAt: "2026-04-22T22:13:02.691Z", - endedAt: null, - exitCode: null, - transcriptPath: ".ade/transcripts/session-mobile.chat.jsonl", - headShaStart: null, - headShaEnd: null, - lastOutputPreview: null, - summary: null, - runtimeState: "running", - resumeCommand: null, - ...overrides, - }; -} - -function renderPane(props: Partial> = {}) { - const session = makeSession(); - const onShowDraftKind = props.onShowDraftKind ?? vi.fn(); - const view = render( - - - , - ); - return { view, onShowDraftKind }; -} - -describe("SessionListPane orchestrator entry", () => { - it("renders an Orchestrator button alongside New Chat", () => { - renderPane(); - const orchestrator = screen.getByTestId("session-list-new-orchestrator-chat"); - expect(orchestrator).toBeTruthy(); - expect(orchestrator.textContent).toContain("Orchestrator"); - }); - - it("clicking Orchestrator dispatches onShowDraftKind('chat-orchestrator')", () => { - const onShowDraftKind = vi.fn(); - const { view } = renderPane({ onShowDraftKind }); - fireEvent.click(within(view.container).getByTestId("session-list-new-orchestrator-chat")); - expect(onShowDraftKind).toHaveBeenCalledWith("chat-orchestrator"); - }); - - it("does NOT dispatch when only New Chat is clicked", () => { - const onShowDraftKind = vi.fn(); - const { view } = renderPane({ onShowDraftKind }); - fireEvent.click(within(view.container).getByRole("button", { name: /^Start a new chat$/i })); - expect(onShowDraftKind).toHaveBeenCalledWith("chat"); - expect(onShowDraftKind).not.toHaveBeenCalledWith("chat-orchestrator"); - }); -}); /** * Verifies the contract from the composer side: the prompt-box button asks diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 1c24e79dc..282df406b 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -204,7 +204,7 @@ export const SessionCard = React.memo(function SessionCard({ {/* Content — 3 rows */}
{/* Row 1: Title + role pill + status dot + relative time */} -
+
{primaryText} - {session.orchestrationRole ? ( - <> - {orchestrationLabel} - - - ) : null} - {staleAgeHours != null ? ( +
+ {session.orchestrationRole ? ( + <> + {orchestrationLabel} + + + ) : null} + {staleAgeHours != null ? ( + + + + ) : null} - + title={dot.label} + className={cn( + "shrink-0 rounded-full", + compact ? "h-2.5 w-2.5" : "h-3 w-3", + dot.cls, + dot.spinning && "animate-spin", + )} + /> + + {relativeTimeCompact(session.endedAt ?? session.startedAt)} - ) : null} - - - {relativeTimeCompact(session.endedAt ?? session.startedAt)} - +
{/* Row 2: Summary/preview line (conditional) */} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index f102256e2..2754fbb25 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -76,8 +76,6 @@ function renderPane(props: Partial> = {}) loading={false} filterLaneId="all" setFilterLaneId={vi.fn()} - filterStatus="all" - setFilterStatus={vi.fn()} q="" setQ={vi.fn()} selectedSessionId={null} @@ -108,16 +106,16 @@ describe("SessionListPane", () => { expect(screen.getByText("Mobile Tool Streaming UI")).toBeTruthy(); }); - it("lets the user set the status filter from the filter panel", () => { - const setFilterStatus = vi.fn(); - const view = renderPane({ setFilterStatus }); + it("lets the user set the group organization from the filter panel", () => { + const setSessionListOrganization = vi.fn(); + const view = renderPane({ setSessionListOrganization }); const filterButton = view.container.querySelector('button[data-tour="work.laneFilter"]'); expect(filterButton).toBeTruthy(); fireEvent.click(filterButton!); - fireEvent.click(within(view.container).getByRole("button", { name: "Running" })); + fireEvent.click(within(view.container).getByRole("button", { name: "Time" })); - expect(setFilterStatus).toHaveBeenCalledWith("running"); + expect(setSessionListOrganization).toHaveBeenCalledWith("by-time"); }); it("bolds only the session name in sidebar cards", () => { diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index a246614af..915695aed 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -1,11 +1,12 @@ import React, { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { CaretDown, CaretRight, Funnel, GitBranch, MagnifyingGlass, Plus, Square, Terminal, Trash, X } from "@phosphor-icons/react"; +import { CaretDown, CaretRight, Funnel, MagnifyingGlass, Plus, Square, Terminal, Trash, X } from "@phosphor-icons/react"; +import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import { SessionCard } from "./SessionCard"; import { LaneCombobox } from "./LaneCombobox"; import { sortLanesForTabs } from "../lanes/laneUtils"; -import type { WorkDraftKind, WorkSessionListOrganization, WorkStatusFilter } from "../../state/appStore"; +import type { WorkDraftKind, WorkSessionListOrganization } from "../../state/appStore"; import { iconGlyph } from "../graph/graphHelpers"; import { SmartTooltip } from "../ui/SmartTooltip"; import { cn } from "../ui/cn"; @@ -14,12 +15,7 @@ import { laneSurfaceTint } from "../lanes/laneDesignTokens"; import { isChatToolType } from "../../lib/sessions"; import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; -const STATUS_FILTER_OPTIONS: Array<{ value: WorkStatusFilter; label: string; description: string }> = [ - { value: "all", label: "All", description: "Show sessions in every state." }, - { value: "running", label: "Running", description: "Show live sessions that are still working." }, - { value: "awaiting-input", label: "Awaiting", description: "Show sessions waiting for a response or approval." }, - { value: "ended", label: "Ended", description: "Show sessions that have finished." }, -]; + const FILTER_OPTION_GRID_CLASS = "grid min-w-0 flex-1 gap-0.5 [grid-template-columns:repeat(auto-fit,minmax(2.4rem,1fr))]"; const FILTER_OPTION_BUTTON_CLASS = "ade-chat-drawer-row min-w-0 truncate rounded-md px-1.5 py-1 text-center text-[10px] font-medium"; @@ -74,12 +70,12 @@ function StickyGroupHeader({ const laneTint = laneSurfaceTint(accentColor, isLane ? "pastel" : "soft"); const laneLabelColor = isLane && laneTint.text ? laneTint.text : accentColor ?? undefined; return ( -
+
- + - - - - - ))} -
-
Group
diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index fa5a716eb..d5c8f6537 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { SidebarSimple } from "@phosphor-icons/react"; import { PaneTilingLayout, type PaneConfig, type PaneSplit } from "../ui/PaneTilingLayout"; import { useWorkSessions } from "./useWorkSessions"; import { SessionListPane } from "./SessionListPane"; @@ -18,6 +17,12 @@ import { ADE_WORK_SIDEBAR_BROWSER_RESIZE_END_EVENT, ADE_WORK_SIDEBAR_BROWSER_RESIZE_START_EVENT, } from "../../lib/workSidebarBrowserResize"; +import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; +import { + consumeLinearIssueWorkContext, + peekLinearIssueWorkContext, + type PendingLinearIssueWorkContext, +} from "../../lib/linearIssueWorkNavigation"; const TERMINALS_TILING_TREE: PaneSplit = { type: "split", @@ -84,10 +89,12 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const [infoPopover, setInfoPopover] = useState(null); const [sessionActionError, setSessionActionError] = useState(null); const [deletingSessionId, setDeletingSessionId] = useState(null); + const [pendingLinearIssueWork, setPendingLinearIssueWork] = useState(null); const [selectedSessionIds, setSelectedSessionIds] = useState>(new Set()); const [selectionAnchorId, setSelectionAnchorId] = useState(null); const workContentPaneRef = useRef(null); const workSidebarPaneRef = useRef(null); + const [workHeaderMount, setWorkHeaderMount] = useState(null); const selectableSessions = useMemo( () => [...work.runningFiltered, ...work.awaitingInputFiltered, ...work.endedFiltered], @@ -186,7 +193,20 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { (session: TerminalSessionSummary) => { work.selectLane(session.laneId); work.focusSession(session.id); - work.navigate(`/lanes?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(session.id)}`); + const params = new URLSearchParams({ + laneId: session.laneId, + focus: "single", + sessionId: session.id, + }); + work.navigate(`/lanes?${params.toString()}`); + }, + [work], + ); + + const handleGoToLaneById = useCallback( + (laneId: string) => { + work.selectLane(laneId); + work.navigate(openLaneInLanesTabPath(laneId)); }, [work], ); @@ -472,8 +492,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }; }, [active, setViewMode, setWorkSidebarTab, showDraftKind]); - const expandSessionsPane = useCallback(() => { - work.setWorkFocusSessionsHidden(false); + const toggleSessionsPane = useCallback(() => { + work.setWorkFocusSessionsHidden(!work.workFocusSessionsHidden); }, [work]); const toggleWorkSidebar = useCallback(() => { work.setWorkSidebarOpen(!work.workSidebarOpen); @@ -542,6 +562,21 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { document.addEventListener("mouseup", onUp); }, [work]); + useEffect(() => { + const laneId = work.draftLaneId?.trim() || ""; + if (!laneId || work.activeItemId) { + setPendingLinearIssueWork(null); + return; + } + setPendingLinearIssueWork(peekLinearIssueWorkContext(laneId)); + }, [work.activeItemId, work.draftLaneId, work.draftKind]); + + const handleInitialLinearIssueContextConsumed = useCallback(() => { + const laneId = work.draftLaneId?.trim() || ""; + if (laneId) consumeLinearIssueWorkContext(laneId); + setPendingLinearIssueWork(null); + }, [work.draftLaneId]); + const workViewArea = useMemo( () => ( ), [ @@ -610,7 +650,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { work.loading, work.workFocusSessionsHidden, work.workSidebarOpen, - expandSessionsPane, + toggleSessionsPane, + workHeaderMount, toggleWorkSidebar, handleOpenChatSession, handleContinueCliSession, @@ -619,7 +660,9 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { handleStopRunningSession, work.reorderLaneSessions, work.openSessionTab, - work.selectLane, + handleGoToLaneById, + pendingLinearIssueWork, + handleInitialLinearIssueContextConsumed, ], ); @@ -679,41 +722,11 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { ], ); - const runningCount = work.runningSessions.length; - const setFocusHidden = work.setWorkFocusSessionsHidden; - - const sessionsHeaderActions = useMemo( - () => ( - - {runningCount > 0 ? ( - - - {runningCount} - - ) : null} - - - ), - [runningCount, setFocusHidden], - ); - const paneConfigs: Record = useMemo( () => ({ sessions: { - title: "Work", - headerActions: sessionsHeaderActions, + title: "", + minimizable: false, // tour anchor: wraps the sessions panel so the Work tour anchors // at the whole pane, not just an inner element. children: ( @@ -726,8 +739,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { loading={work.loading} filterLaneId={work.filterLaneId} setFilterLaneId={work.setFilterLaneId} - filterStatus={work.filterStatus} - setFilterStatus={work.setFilterStatus} q={work.q} setQ={work.setQ} selectedSessionId={work.selectedSessionId} @@ -776,7 +787,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { handleBulkDeleteSelected, handleInfoClick, handleContextMenu, - sessionsHeaderActions, workViewWithSidebar, ], ); @@ -797,12 +807,15 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { {workViewWithSidebar}
) : ( - +
+
+ +
)} = [ - { id: "git", label: "Git", Icon: GitBranch }, - { id: "files", label: "Files", Icon: FolderOpen }, - { id: "ios", label: "iOS Sim", Icon: DeviceMobile }, - { id: "app-control", label: "App Control", Icon: Desktop }, - { id: "browser", label: "Browser", Icon: Globe }, +const WORK_SIDEBAR_TABS: Array> = [ + { + id: "git", + label: "Git", + icon: GitBranch, + gradient: "radial-gradient(circle, rgba(52,211,153,0.42) 0%, transparent 70%)", + color: "#34d399", + }, + { + id: "files", + label: "Files", + icon: FolderOpen, + gradient: "radial-gradient(circle, rgba(251,191,36,0.38) 0%, transparent 70%)", + color: "#fbbf24", + }, + { + id: "ios", + label: "iOS Sim", + icon: DeviceMobile, + gradient: "radial-gradient(circle, rgba(96,165,250,0.4) 0%, transparent 70%)", + color: "#60a5fa", + }, + { + id: "app-control", + label: "App Control", + icon: Desktop, + gradient: "radial-gradient(circle, rgba(167,139,250,0.42) 0%, transparent 70%)", + color: "#a78bfa", + }, + { + id: "browser", + label: "Browser", + icon: Globe, + gradient: "radial-gradient(circle, rgba(34,211,238,0.38) 0%, transparent 70%)", + color: "#22d3ee", + }, ]; export type WorkSidebarContextTarget = @@ -205,15 +230,17 @@ export function WorkSidebar({ useEffect(() => { if (!active) return undefined; if (tab !== "app-control") return undefined; + const appControl = window.ade?.appControl; + if (!appControl?.getStatus || !appControl.onEvent) return undefined; let cancelled = false; - void window.ade.appControl.getStatus() + void appControl.getStatus() .then((status) => { if (!cancelled) setAppControlSession(status.activeSession ?? null); }) .catch(() => { if (!cancelled) setAppControlSession(null); }); - const unsubscribe = window.ade.appControl.onEvent((event) => { + const unsubscribe = appControl.onEvent((event) => { if (event.type === "session-started" || event.type === "session-updated") { setAppControlSession(event.session ?? null); } else if (event.type === "session-stopped") { @@ -229,15 +256,17 @@ export function WorkSidebar({ useEffect(() => { if (!active) return undefined; if (tab !== "ios") return undefined; + const iosSimulator = window.ade?.iosSimulator; + if (!iosSimulator?.getStatus || !iosSimulator.onEvent) return undefined; let cancelled = false; - void window.ade.iosSimulator.getStatus() + void iosSimulator.getStatus() .then((status) => { if (!cancelled) setIosSession(status.activeSession ?? null); }) .catch(() => { if (!cancelled) setIosSession(null); }); - const unsubscribe = window.ade.iosSimulator.onEvent((event) => { + const unsubscribe = iosSimulator.onEvent((event) => { if (event.type === "session-started" || event.type === "session-updated") { setIosSession(event.session ?? null); } else if (event.type === "session-released") { @@ -501,69 +530,37 @@ export function WorkSidebar({ tab, ]); - const activeSessionLabel = activeSession - ? `${formatToolTypeLabel(activeSession.toolType)} · ${activeSession.laneName}` - : activeLane?.name ?? "No active session"; - return (
- -
+ {placeWorkGlassHeader( + + {visibleSessions.map((session, index) => { + const isActive = activeSession?.id === session.id; + const isBusy = session.ptyId ? closingPtyIds.has(session.ptyId) : false; + const laneColor = laneColorById.get(session.laneId) ?? null; + const awaiting = isSessionAwaitingInput(session); + const dropEdge = dragState + && dragState.laneId === session.laneId + && dragState.overIndex === index + && dragState.sessionId !== session.id + ? dragState.overEdge + : null; + return ( + onSelectItem(session.id)} + onClose={() => onCloseItem(session.id)} + onContextMenu={(e) => handleContextMenu(session, e)} + dragProps={buildLaneDragProps({ laneId: session.laneId, sessionId: session.id, index })} + /> + ); + })} + {visibleSessions.length === 0 ? ( + + + + ) : null} +
+ )} + />, + headerMountEl, + )} {tabBody} {laneContextMenuPortal} @@ -1843,22 +1916,20 @@ export function WorkViewArea({ return (
-
- - -
- -
-
-
- {resolvedTabGroups.map((group) => { + {placeWorkGlassHeader( + + {resolvedTabGroups.map((group) => { const hasActive = group.sessionIds.includes(activeSession?.id ?? ""); const isLaneGroup = group.kind === "lane"; const laneId = isLaneGroup && group.id.startsWith("lane:") ? group.id.slice("lane:".length) : null; @@ -1870,6 +1941,7 @@ export function WorkViewArea({ : laneSurfaceTint(null); const bandCssVars = { "--lane-band-color": bandColor ?? "color-mix(in srgb, var(--color-fg) 28%, transparent)", + ...(bandColor ? { "--lane-band-label-color": bandColor } : {}), "--lane-band-bg": bandTint.background, "--lane-band-header-bg": bandColor ? `color-mix(in srgb, ${bandColor} 12%, transparent)` @@ -1902,7 +1974,7 @@ export function WorkViewArea({ triggerLaneContextMenu(laneId, e); }} > - + {group.label} @@ -1926,37 +1998,24 @@ export function WorkViewArea({ effect: `${group.sessions.length} session${group.sessions.length === 1 ? "" : "s"} in this group.`, }} > -
toggleTabGroupCollapsed(group.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleTabGroupCollapsed(group.id); - } - }} onContextMenu={(e) => { if (!laneId) return; triggerLaneContextMenu(laneId, e); }} > - - {group.label} - - - {group.sessions.length} - - - - -
+ +
); })} - - - + {visibleSessions.length === 0 ? ( + + + + ) : null}
-
- -
+ )} + />, + headerMountEl, + )} {tabBody} {laneContextMenuPortal} @@ -2028,9 +2084,9 @@ const TILING_PRESET_OPTIONS: ReadonlyArray<{ description: string; icon: React.ReactNode; }> = [ - { preset: "auto", label: "Auto", description: "Balanced grid (default).", icon: }, - { preset: "rows", label: "Rows", description: "Stack vertically, one full-width row per session.", icon: }, - { preset: "columns", label: "Columns", description: "Side by side, one full-height column per session.", icon: }, + { preset: "auto", label: "Auto", description: "Balanced grid (default).", icon: }, + { preset: "rows", label: "Rows", description: "Stack vertically, one full-width row per session.", icon: }, + { preset: "columns", label: "Columns", description: "Side by side, one full-height column per session.", icon: }, ]; function ArrangeMenu({ @@ -2064,7 +2120,7 @@ function ArrangeMenu({ return (
); @@ -2143,35 +2199,26 @@ function ViewModeToggle({ setViewMode: (mode: WorkViewMode) => void; }) { return ( -
+
{([ - { mode: "tabs" as const, icon: , label: "Tabs", title: "Tab View", description: "Display sessions as tabs in a single panel." }, - { mode: "grid" as const, icon: , label: "Grid", title: "Grid View", description: "Display sessions side by side in a tiled grid." }, - ]).map(({ mode, icon, label, title, description }) => { + { mode: "tabs" as const, icon: , title: "Tab view", description: "Display sessions as tabs in a single panel." }, + { mode: "grid" as const, icon: , title: "Grid view", description: "Display sessions side by side in a tiled grid." }, + ]).map(({ mode, icon, title, description }) => { const active = viewMode === mode; return ( - + ); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 33e203f41..d72c1f652 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -1119,16 +1119,6 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const needle = q.trim().toLowerCase(); return sessions.filter((session) => { if (filterLaneId !== "all" && session.laneId !== filterLaneId) return false; - if (filterStatus !== "all") { - const bucket = sessionStatusBucket({ - status: session.status, - lastOutputPreview: session.lastOutputPreview, - runtimeState: session.runtimeState, - toolType: session.toolType, - pendingInputItemId: session.pendingInputItemId, - }); - if (bucket !== filterStatus) return false; - } if (!needle) return true; if (needle.startsWith("lane:")) { @@ -1155,7 +1145,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) (session.resumeCommand ?? "").toLowerCase().includes(needle) ); }); - }, [sessions, filterLaneId, filterStatus, q]); + }, [sessions, filterLaneId, q]); const { runningFiltered, awaitingInputFiltered, endedFiltered } = useMemo(() => { const running: TerminalSessionSummary[] = []; diff --git a/apps/desktop/src/renderer/components/ui/FloatingPane.tsx b/apps/desktop/src/renderer/components/ui/FloatingPane.tsx index 832966dfe..d2f9a5b95 100644 --- a/apps/desktop/src/renderer/components/ui/FloatingPane.tsx +++ b/apps/desktop/src/renderer/components/ui/FloatingPane.tsx @@ -42,6 +42,7 @@ function getDropZoneStyle(edge: DropEdge): React.CSSProperties | null { export function FloatingPane({ id, title, + titleContent, icon: Icon, meta, headerActions, @@ -69,6 +70,7 @@ export function FloatingPane({ }: { id: string; title: string; + titleContent?: React.ReactNode; icon?: PhosphorIcon; meta?: React.ReactNode; headerActions?: React.ReactNode; @@ -177,7 +179,7 @@ export function FloatingPane({ onDragStart={isDraggable ? handleDragStart : undefined} onDragEnd={isDraggable ? handleDragEnd : undefined} > -
+
{isDraggable ? ( ) : null} @@ -211,7 +213,11 @@ export function FloatingPane({ minimized ? "text-fg/70" : "text-muted-fg/50" )} /> ) : null} - {title} + {titleContent ? ( +
{titleContent}
+ ) : ( + {title} + )} {meta && !minimized ? {meta} : null}
{headerActions ? ( @@ -229,7 +235,7 @@ export function FloatingPane({ onDragStart={isDraggable ? handleDragStart : undefined} onDragEnd={isDraggable ? handleDragEnd : undefined} > -
+
{isDraggable ? ( ) : null} diff --git a/apps/desktop/src/renderer/components/ui/GlowMenu.tsx b/apps/desktop/src/renderer/components/ui/GlowMenu.tsx new file mode 100644 index 000000000..cba073d34 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/GlowMenu.tsx @@ -0,0 +1,197 @@ +import * as React from "react"; +import type { Icon } from "@phosphor-icons/react"; +import { motion, type Transition } from "motion/react"; +import { cn } from "./cn"; + +export type GlowMenuItem = { + id: T; + label: string; + icon: Icon; + gradient: string; + color: string; +}; + +type GlowMenuProps = { + className?: string; + items: ReadonlyArray>; + activeItem: T; + onItemClick: (id: T) => void; + compact?: boolean; + variant?: "pill" | "flat"; +}; + +const itemVariants = { + initial: { rotateX: 0, opacity: 1 }, + hover: { rotateX: -90, opacity: 0 }, +}; + +const backVariants = { + initial: { rotateX: 90, opacity: 0 }, + hover: { rotateX: 0, opacity: 1 }, +}; + +const glowVariants = { + initial: { opacity: 0, scale: 0.8 }, + hover: { + opacity: 1, + scale: 2, + transition: { + opacity: { duration: 0.5, ease: [0.4, 0, 0.2, 1] as const }, + scale: { duration: 0.5, type: "spring" as const, stiffness: 300, damping: 25 }, + }, + }, +}; + +const navGlowVariants = { + initial: { opacity: 0 }, + hover: { + opacity: 1, + transition: { + duration: 0.5, + ease: [0.4, 0, 0.2, 1] as const, + }, + }, +}; + +const sharedTransition: Transition = { + type: "spring", + stiffness: 100, + damping: 20, + duration: 0.5, +}; + +function GlowMenuItemFace({ + item, + active, + compact, +}: { + item: GlowMenuItem; + active: boolean; + compact: boolean; +}) { + const Icon = item.icon; + return ( + <> + + + + {item.label} + + ); +} + +export function GlowMenu({ + className, + items, + activeItem, + onItemClick, + compact = false, + variant = "pill", +}: GlowMenuProps) { + const flat = variant === "flat"; + return ( + + {!flat ? ( + + ) : null} +
    + {items.map((item) => { + const active = item.id === activeItem; + return ( + + + + ); + })} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/ui/LaneBranchInline.tsx b/apps/desktop/src/renderer/components/ui/LaneBranchInline.tsx new file mode 100644 index 000000000..05c9c0878 --- /dev/null +++ b/apps/desktop/src/renderer/components/ui/LaneBranchInline.tsx @@ -0,0 +1,89 @@ +import type { CSSProperties, MouseEvent } from "react"; +import { cn } from "./cn"; +import { BranchIcon, LaneIcon } from "./vcsIcons"; + +/** Lane + branch metadata row (sidebar lane tabs, grid tile headers, etc.). */ +export function LaneBranchInline({ + laneName, + branchLabel, + laneColor, + laneClassName, + branchClassName, + iconSize = 12, + textClassName = "text-[12px] font-medium leading-snug", + style, + onLaneClick, + onLaneContextMenu, +}: { + laneName: string; + branchLabel?: string | null; + laneColor?: string | null; + laneClassName?: string; + branchClassName?: string; + iconSize?: number; + textClassName?: string; + style?: CSSProperties; + onLaneClick?: () => void; + onLaneContextMenu?: (event: MouseEvent) => void; +}) { + const branchText = branchLabel?.trim() ?? ""; + const showBranch = branchText.length > 0; + const laneColorStyle = laneColor?.trim() ? { color: laneColor.trim() } : undefined; + + const laneClusterClassName = cn( + "ade-lane-branch-inline-lane inline-flex min-w-0 shrink-0 items-center gap-1.5 overflow-hidden border-0 bg-transparent p-0", + onLaneClick ? "cursor-pointer transition-opacity hover:opacity-80" : undefined, + laneClassName, + ); + const laneClusterInner = ( + <> + + {laneName} + + ); + const laneCluster = onLaneClick ? ( + + ) : ( + + {laneClusterInner} + + ); + + return ( +
+ {laneCluster} + {showBranch ? ( + + + {branchText} + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx b/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx index ea91778d7..b2c22eac1 100644 --- a/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx +++ b/apps/desktop/src/renderer/components/ui/PaneTilingLayout.tsx @@ -25,6 +25,7 @@ export type { PaneLeaf, PaneSplit, PaneLayoutEntry } from "./paneTreeOps"; export type PaneConfig = { title: string; + titleContent?: React.ReactNode; icon?: PhosphorIcon; meta?: React.ReactNode; minimizable?: boolean; @@ -386,6 +387,7 @@ export function PaneTilingLayout({ + ); +} + +/** Git branch ref (e.g. `main`, `refs/heads/feature`). */ +export function BranchIcon({ size = 12, weight = "regular", className, ...props }: IconProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index be4e665a7..c24b12dc2 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -103,7 +103,13 @@ function HeaderProviderUsageChip({ ); } -export function HeaderUsageControl() { +export function HeaderUsageControl({ + variant = "chip", + onMenuActivate, +}: { + variant?: "chip" | "menu-row"; + onMenuActivate?: () => void; +} = {}) { const [open, setOpen] = useState(false); const [snapshot, setSnapshot] = useState(null); const [providerConnections, setProviderConnections] = useState(null); @@ -253,36 +259,73 @@ export function HeaderUsageControl() { buttonTitle = "Usage"; } + const openUsage = () => { + setOpen(true); + onMenuActivate?.(); + }; + + const trigger = variant === "menu-row" ? ( + + ) : ( + + ); + return ( <> - + {trigger} {open ? (
.ade-floating-pane-header { + display: none; +} + +/* Work split gutter: 1px divider at rest; wider only while hovering/dragging */ +.ade-work-surface .ade-pane-gutter.vertical { + width: 1px !important; + min-width: 1px !important; + background: var(--work-pane-border) !important; + border: none !important; + box-shadow: none !important; +} + +.ade-work-surface .ade-pane-gutter.vertical::before { + opacity: 0; +} + +.ade-work-surface .ade-pane-gutter.vertical:hover, +.ade-work-surface .ade-pane-gutter.vertical[data-resize-handle-active] { + width: 4px !important; + background: color-mix(in srgb, var(--color-accent) 14%, transparent) !important; + border-radius: 2px; +} + +.ade-work-surface .ade-pane-gutter.vertical:hover::before, +.ade-work-surface .ade-pane-gutter.vertical[data-resize-handle-active]::before { + opacity: 1; + background: var(--color-accent); + box-shadow: 0 0 6px 1px color-mix(in srgb, var(--color-accent) 35%, transparent); +} + +/* Sessions pane uses the gutter as the sole divider — no stacked border */ .ade-work-surface [data-pane-id="sessions"] { - border-right: 1px solid var(--work-pane-border); + border-right: none; } /* Lane Combobox Popover */ @@ -926,16 +994,36 @@ h6 { animation: ade-popover-in 120ms cubic-bezier(0.34, 1.56, 0.64, 1); } +.ade-lane-popover-search-row { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + flex-shrink: 0; + border-bottom: 1px solid var(--work-pane-border); +} + .ade-lane-popover-search { height: 32px; border: none; - border-bottom: 1px solid var(--work-pane-border); background: transparent; - padding: 0 10px; + padding: 0; font-size: 11px; color: var(--color-fg); outline: none; + box-shadow: none; width: 100%; + min-width: 0; + appearance: none; + -webkit-appearance: none; +} + +.ade-lane-popover-search:focus, +.ade-lane-popover-search:focus-visible { + outline: none; + box-shadow: none; + border: none; + background: transparent; } .ade-lane-popover-search::placeholder { @@ -945,6 +1033,7 @@ h6 { .ade-lane-popover-list { overflow-y: auto; + overscroll-behavior: contain; padding: 4px; flex: 1; min-height: 0; @@ -964,12 +1053,13 @@ h6 { background: transparent; width: 100%; text-align: left; - transition: background 80ms ease; + transition: box-shadow 120ms ease, background 120ms ease; } -.ade-lane-popover-item:hover, -.ade-lane-popover-item[data-highlighted="true"] { - background: var(--work-popover-item-hover); +.ade-lane-popover-item:hover { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 4px 14px -6px rgba(0, 0, 0, 0.55); } .ade-lane-popover-item[data-selected="true"] { @@ -977,11 +1067,70 @@ h6 { font-weight: 500; } -.ade-lane-popover-dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; +.ade-lane-popover-item[data-selected="true"]:hover { + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + 0 6px 16px -6px rgba(0, 0, 0, 0.62); +} + +.ade-lane-popover-item-featured { + justify-content: center; + gap: 6px; + padding-top: 8px; + padding-bottom: 8px; +} + +.ade-orchestrator-rainbow-text { + background: linear-gradient( + 90deg, + #ff5f5f 0%, + #ff9b3f 16%, + #f7d05c 33%, + #59d97f 50%, + #4f93ff 66%, + #a566ff 83%, + #ff5f5f 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + font-weight: 500; +} + +@media (prefers-reduced-motion: no-preference) { + .ade-orchestrator-rainbow-text { + animation: ade-orchestrator-rainbow-text-shift 10s linear infinite; + } +} + +@keyframes ade-orchestrator-rainbow-text-shift { + to { + background-position: -200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .ade-orchestrator-rainbow-text { + animation: none; + background-position: 0 0; + } +} + +.ade-lane-popover-branch-row { + display: flex; + align-items: center; + gap: 4px; + margin-left: 18px; + margin-top: 3px; + min-width: 0; +} + +.ade-lane-popover-branch-label { + font-size: 10px; + line-height: 1.2; + color: var(--color-muted-fg); + opacity: 0.92; } /* Segmented Control for Work tab */ @@ -2481,13 +2630,18 @@ button:active, [role="button"]:active { .ade-liquid-glass-menu { border-radius: 16px; - background: - linear-gradient(180deg, color-mix(in srgb, var(--chat-panel-bg-strong) 96%, transparent) 0%, color-mix(in srgb, var(--chat-panel-bg) 94%, transparent) 100%); - border-color: color-mix(in srgb, var(--chat-glass-border) 120%, var(--chat-liquid-highlight, var(--chat-glass-sheen)) 28%); + border: 1px solid var(--work-popover-border, var(--chat-panel-border)); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-card) 94%, var(--color-bg) 6%) 0%, + var(--color-card) 100% + ); + backdrop-filter: none; + -webkit-backdrop-filter: none; box-shadow: - inset 0 1px 0 color-mix(in srgb, var(--chat-glass-highlight) 115%, transparent), + inset 0 1px 0 color-mix(in srgb, var(--chat-glass-highlight) 55%, transparent), 0 24px 56px -20px rgba(0, 0, 0, 0.58), - 0 0 0 1px color-mix(in srgb, var(--chat-liquid-highlight, var(--chat-glass-sheen)) 20%, transparent); + 0 0 0 1px color-mix(in srgb, var(--chat-liquid-highlight, var(--chat-glass-sheen)) 12%, transparent); } .ade-liquid-glass-pill { @@ -2553,16 +2707,230 @@ button:active, [role="button"]:active { — follow Apple HIG: content leads, glass recedes, concentric radii ═══════════════════════════════════════════════════════════ */ -/* Work view tab strip — flat against pane bg (no hairline or glass strip) */ +/* Work view tab strip — shares one chrome surface with the chat header below */ .ade-work-glass-header { + --ade-work-header-side-h: 28px; + --ade-work-tab-h: var(--ade-work-header-side-h); + --ade-work-band-gap: 4px; + --ade-work-band-expanded-h: var(--ade-work-tab-h); position: relative; - background: transparent; + height: fit-content; + background: var(--work-chrome-surface); border-bottom: none; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } +.ade-work-glass-header-flex { + display: flex; + align-items: stretch; + gap: 6px; + height: var(--ade-work-header-side-h); + min-height: var(--ade-work-header-side-h); + max-height: var(--ade-work-header-side-h); + box-sizing: border-box; +} + +.ade-work-glass-header-left, +.ade-work-glass-header-center { + display: flex; + align-items: stretch; + min-height: var(--ade-work-header-side-h); +} + +.ade-work-tab-strip-scroll { + height: 100%; + min-height: var(--ade-work-header-side-h); + align-items: stretch; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.ade-work-tab-strip-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.ade-work-glass-header-left { + flex-shrink: 0; + min-width: 0; +} + +.ade-work-glass-header-left > * { + flex-shrink: 0; +} + +.ade-work-glass-header-center { + position: relative; + flex: 1 1 auto; + min-width: 0; +} + +.ade-work-glass-header-center--tools-dock { + --ade-work-tools-scroll-spacer: calc(var(--ade-work-header-side-h, 28px) + 80px); +} + +.ade-work-tab-strip-scroll--tools-dock { + scroll-padding-inline-end: var(--ade-work-tools-scroll-spacer, 108px); +} + +.ade-work-tab-strip-scroll-inner { + display: inline-flex; + align-items: stretch; + gap: 4px; + height: 100%; + min-height: var(--ade-work-header-side-h, 28px); + vertical-align: top; +} + +.ade-work-tab-strip-scroll-spacer { + flex: 0 0 var(--ade-work-tools-scroll-spacer, 108px); + width: var(--ade-work-tools-scroll-spacer, 108px); + min-width: var(--ade-work-tools-scroll-spacer, 108px); + height: 1px; + align-self: stretch; + pointer-events: none; +} + +.ade-work-header-tools-dock { + position: absolute; + right: 0; + top: 50%; + z-index: 2; + display: flex; + align-items: center; + transform: translateY(-50%); + padding-left: 24px; + background: linear-gradient( + to right, + transparent 0%, + color-mix(in srgb, var(--work-chrome-surface) 50%, transparent) 45%, + var(--work-chrome-surface) 100% + ); + pointer-events: none; +} + +.ade-work-header-tools-dock > * { + pointer-events: auto; +} + +.ade-work-header-side-control { + height: var(--ade-work-header-side-h); + min-height: var(--ade-work-header-side-h); +} + +.ade-work-tools-toggle { + height: var(--ade-work-header-side-h); + min-height: var(--ade-work-header-side-h); + background: color-mix(in srgb, white 3.5%, var(--work-chrome-surface)); + border-color: var(--work-popover-border, var(--shell-control-border)); +} + +.ade-work-tools-toggle:hover { + background: color-mix(in srgb, white 7%, var(--work-chrome-surface)); + border-color: var(--shell-control-hover-border); +} + +.ade-work-tools-toggle[aria-pressed="true"] { + background: color-mix(in srgb, var(--color-accent) 14%, var(--work-chrome-surface) 86%); + border-color: color-mix(in srgb, var(--color-accent) 35%, var(--shell-control-border)); +} + +.ade-chat-shell-header { + background: var(--work-chrome-surface, var(--color-card)); + border-style: solid; + border-width: 0 0 1px 0; + border-color: var(--chat-panel-border); + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +/* Grid arrange preset slide-out — same height as header controls */ +.ade-work-arrange-menu-root { + height: var(--ade-work-header-side-h); + max-height: var(--ade-work-header-side-h); +} + +.ade-work-arrange-menu { + height: var(--ade-work-header-side-h); + max-height: var(--ade-work-header-side-h); + align-items: center; +} + +.ade-work-arrange-menu-item { + height: 100%; + max-height: var(--ade-work-header-side-h); + padding-inline: 8px; + font-size: 10px; + line-height: 1; +} + +/* Origin UI–style grouped buttons (flat segmented control, no liquid glass) */ +.ade-work-view-mode-toggle { + display: inline-flex; + flex-direction: row; + align-items: stretch; + width: calc(var(--ade-work-header-side-h, 28px) * 2); + min-width: calc(var(--ade-work-header-side-h, 28px) * 2); + height: var(--ade-work-header-side-h, 28px); + min-height: var(--ade-work-header-side-h, 28px); + border-radius: 6px; + border: 1px solid color-mix(in srgb, white 8%, transparent); + background: transparent; + box-shadow: 0 1px 2px -1px rgba(0, 0, 0, 0.14); + overflow: hidden; +} + +.ade-work-view-mode-toggle-slot { + display: flex !important; + flex: 1 1 0; + min-height: 0; + min-width: 0; + height: 100%; +} + +.ade-work-view-mode-toggle-slot + .ade-work-view-mode-toggle-slot { + border-top: none; + border-left: 1px solid color-mix(in srgb, white 8%, transparent); +} + +.ade-work-view-mode-toggle-btn { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 0; + padding: 0; + color: var(--color-muted-fg); + background: transparent; + border: none; + cursor: pointer; + box-shadow: inset 0 1px 0 transparent; + transition: color 120ms ease, background 120ms ease, box-shadow 120ms ease; +} + +.ade-work-view-mode-toggle-btn:hover:not([aria-pressed="true"]) { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 4%, transparent); +} + +.ade-work-view-mode-toggle-btn[aria-pressed="true"] { + color: var(--color-fg); + background: color-mix(in srgb, var(--color-fg) 8%, transparent); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 6%, transparent); +} + +.ade-work-view-mode-toggle-btn:focus-visible { + position: relative; + z-index: 1; + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-fg) 24%, transparent); +} + /* Session tile shell — very subtle so content stays primary */ .ade-work-glass-tile { position: relative; @@ -2627,6 +2995,10 @@ button:active, [role="button"]:active { border-bottom-color: color-mix(in srgb, var(--lane-accent) 18%, var(--work-pane-border)); } +.ade-work-grid-tiling .ade-floating-pane-header .cursor-grab { + display: none; +} + .ade-work-grid-tiling .ade-floating-pane.ade-floating-pane--lane-accent:hover { border-color: color-mix(in srgb, var(--lane-accent) 28%, var(--chat-glass-border)); box-shadow: @@ -2710,34 +3082,92 @@ button:active, [role="button"]:active { /* Work tab — roomier flat/grouped tabs with full lane tint. */ .ade-work-tab { position: relative; - display: inline-flex; + display: inline-grid; + grid-template-columns: auto minmax(3.5rem, 1fr) 6px; align-items: center; - gap: 8px; - padding: 0 12px; - min-height: 40px; - height: 40px; - font-size: 12px; + column-gap: 6px; + padding: 0 8px; + min-width: 124px; + min-height: var(--ade-work-tab-h, 28px); + height: 100%; + max-height: var(--ade-work-header-side-h, 28px); + align-self: stretch; + font-size: 10px; font-weight: 400; background: var(--lane-tab-tint, transparent); color: var(--color-muted-fg); border: none; - border-radius: 8px; + border-radius: 7px; cursor: pointer; + box-shadow: none; transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease, opacity 120ms ease; - opacity: 0.85; + opacity: 0.92; +} + +.ade-work-tab-select { + display: contents; + cursor: pointer; + border: none; + background: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: left; +} + +.ade-work-tab-logo { + grid-column: 1; + align-self: center; +} + +.ade-work-tab-label { + grid-column: 2; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1; +} + +.ade-work-tab-status { + grid-column: 3; + grid-row: 1; + width: 6px; + height: 6px; + justify-self: center; + align-self: center; + transition: opacity 120ms ease; +} + +.ade-work-tab-close { + position: absolute; + top: 50%; + right: 5px; + z-index: 1; + width: 12px; + height: 12px; + transform: translateY(-50%); +} + +.ade-work-tab:hover .ade-work-tab-status { + opacity: 0; } -.ade-work-tab:hover { +.ade-work-tab:hover:not(.ade-work-tab--active) { opacity: 1; color: var(--color-fg); } .ade-work-tab--grouped { - min-height: 36px; - height: 36px; - padding: 0 10px; - font-size: 11px; - border-radius: 6px; + padding: 0 6px; + min-width: 118px; + font-size: 9.5px; + border-radius: 5px; +} + +.ade-work-tab--grouped .ade-work-tab-close { + right: 4px; } .ade-work-tab--active { @@ -2762,8 +3192,8 @@ button:active, [role="button"]:active { .ade-work-tab--drop-after::after { content: ""; position: absolute; - top: 4px; - bottom: 4px; + top: 3px; + bottom: 3px; width: 2px; background: var(--lane-drop-indicator, color-mix(in srgb, var(--color-fg) 60%, transparent)); border-radius: 1px; @@ -2773,58 +3203,101 @@ button:active, [role="button"]:active { .ade-work-tab--drop-before::before { left: -3px; } .ade-work-tab--drop-after::after { right: -3px; } -/* Swim-lane band per lane (grouped-by-lane mode) */ +/* Swim-lane band per lane (grouped-by-lane mode) — label + tabs on one row */ .ade-work-lane-band { position: relative; display: flex; - flex-direction: column; + flex-direction: row; align-items: stretch; min-width: 0; flex-shrink: 0; - gap: 2px; + gap: var(--ade-work-band-gap, 4px); + height: 100%; + min-height: var(--ade-work-band-expanded-h, 28px); + max-height: var(--ade-work-header-side-h, 28px); } .ade-work-lane-band-header { display: flex; align-items: center; - gap: 6px; - padding: 0 4px 2px; - font-size: 10px; + gap: 2px; + padding: 0 4px 0 2px; + height: 100%; + min-height: var(--ade-work-tab-h, 28px); + max-height: var(--ade-work-header-side-h, 28px); + font-size: 7.5px; line-height: 1; - letter-spacing: 0.02em; + letter-spacing: 0.05em; text-transform: uppercase; - color: var(--lane-band-color, var(--color-fg)); + color: var(--lane-band-label-color, var(--color-fg)); cursor: pointer; user-select: none; - opacity: 0.75; - transition: opacity 120ms ease; + flex-shrink: 0; } -.ade-work-lane-band-header:hover { - opacity: 1; +.ade-work-lane-band-collapse { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 14px; + min-width: 14px; + height: 100%; + min-height: var(--ade-work-tab-h, 28px); + max-height: var(--ade-work-header-side-h, 28px); + padding: 0; + border: none; + border-radius: 4px; + color: var(--lane-band-label-color, color-mix(in srgb, var(--color-fg) 55%, transparent)); + background: transparent; + cursor: pointer; + opacity: 0.55; + transition: opacity 120ms ease, background 120ms ease, color 120ms ease; +} + +.ade-work-lane-band-collapse:hover { + opacity: 0.95; + background: color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 10%, transparent); +} + +.ade-work-lane-band-collapse.ade-work-lane-band--active { + opacity: 0.85; + color: var(--lane-band-label-color, var(--color-fg)); +} + +.ade-work-lane-band-collapse:focus-visible { + outline: none; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 28%, transparent); } .ade-work-lane-band-tabs { display: flex; flex-wrap: nowrap; - align-items: center; - gap: 6px; + align-items: stretch; + gap: 4px; padding: 0; + min-width: 0; + height: 100%; } .ade-work-lane-band--collapsed { - flex-direction: column; + flex-direction: row; align-items: center; - justify-content: center; - gap: 3px; - min-width: 96px; - max-width: 160px; - padding: 6px 10px; - border-radius: 8px; + justify-content: flex-start; + gap: 4px; + height: var(--ade-work-tab-h, 28px); + min-height: var(--ade-work-tab-h, 28px); + max-height: var(--ade-work-header-side-h, 28px); + min-width: 0; + max-width: 180px; + padding: 0 8px; + border-radius: 6px; border: none; background: color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 10%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lane-band-color, var(--color-fg)) 18%, transparent); - color: var(--lane-band-color, var(--color-fg)); + color: var(--lane-band-label-color, var(--color-fg)); + font-size: 10.5px; + line-height: 1; cursor: pointer; transition: background 120ms ease, box-shadow 120ms ease; } @@ -2839,20 +3312,22 @@ button:active, [role="button"]:active { } .ade-work-lane-band-collapsed-label { - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; + flex: 1 1 auto; + min-width: 0; overflow: hidden; - font-size: 11px; - line-height: 1.2; - text-align: center; - max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 9.5px; + line-height: 1; + text-align: left; } .ade-work-lane-band-collapsed-count { - font-size: 10px; + flex-shrink: 0; + font-size: 9px; line-height: 1; opacity: 0.55; + font-variant-numeric: tabular-nums; } /* Pulse used when navigating to a lane row from the Work-tab context menu. */ @@ -2866,18 +3341,184 @@ button:active, [role="button"]:active { animation: ade-lane-row-pulse 600ms ease-out; } +.ade-work-tab-strip-scroll { + height: 100%; +} + +.ade-work-tab-strip-scroll > .flex, +.ade-work-tab-strip-scroll-inner { + height: 100%; + align-items: stretch; +} + +.ade-work-tab-strip-scroll .ade-work-tab { + height: 100%; +} + .ade-work-tab-strip-roomy { display: flex; flex-wrap: nowrap; - align-items: center; + flex-shrink: 0; + align-items: stretch; gap: 6px; + height: 100%; + min-height: var(--ade-work-header-side-h); +} + +/* Session list sidebar toolbar — search hides when pane is narrow; New Chat + Filter stay visible */ +.ade-session-list-toolbar { + container-type: inline-size; + container-name: session-list-toolbar; +} + +.ade-session-list-toolbar-row { + min-width: 0; +} + +.ade-session-list-toolbar-new-chat, +.ade-session-list-toolbar-filter { + flex-shrink: 0; +} + +.ade-session-list-toolbar-search { + flex: 1 1 auto; + min-width: 0; +} + +@container session-list-toolbar (max-width: 240px) { + .ade-session-list-toolbar-search { + display: none; + } + + .ade-session-list-toolbar-row { + justify-content: space-between; + } +} + +@container session-list-toolbar (max-width: 130px) { + .ade-session-list-toolbar-new-chat-label { + display: none; + } + + .ade-session-list-toolbar-new-chat { + width: 1.75rem; + justify-content: center; + padding-inline: 0; + } +} + +/* Chat composer footer — permission preset collapses to dot-only in narrow grid tiles */ +.ade-chat-composer-footer { + container-type: inline-size; + container-name: chat-composer; +} + +@container chat-composer (max-width: 400px) { + .ade-chat-composer-permission-trigger { + min-width: 1.5rem; + justify-content: center; + padding-inline: 0; + } + + .ade-chat-composer-permission-label, + .ade-chat-composer-permission-chevron { + display: none; + } + + .ade-chat-composer-fast-toggle { + min-width: 1.5rem; + gap: 0; + padding-inline: 0; + justify-content: center; + } + + .ade-chat-composer-fast-label { + display: none; + } +} + +/* Lane group headers — lane name wins; branch cluster hides/truncates first */ +.ade-lane-group-header { + container-type: inline-size; + container-name: lane-group-header; +} + +.ade-session-lane-header { + width: 100%; + min-width: 0; +} + +.ade-floating-pane-title-slot { + container-type: inline-size; + container-name: session-lane-header; +} + +.ade-session-lane-header-title { + flex-shrink: 0; +} + +/* Sidebar lane tabs: lane name wins over branch */ +.ade-lane-group-header .ade-lane-branch-inline-lane { + flex-shrink: 0; +} + +.ade-lane-branch-inline-lane { + flex-shrink: 0; +} + +/* Grid tile headers: chat name wins; branch → lane truncate/hide first */ +.ade-session-lane-header .ade-lane-branch-inline-lane { + flex-shrink: 1; + min-width: 0; +} + +.ade-lane-branch-inline-branch { + flex-shrink: 999; + min-width: 0; +} + +@container lane-group-header (max-width: 240px) { + .ade-lane-group-header-branch, + .ade-lane-branch-inline-branch { + display: none; + } + + .ade-lane-group-header-lane { + min-width: 0; + flex-shrink: 1; + max-width: calc(100% - 3.5rem); + } +} + +@container session-lane-header (max-width: 240px) { + .ade-session-lane-header .ade-lane-branch-inline-branch { + display: none; + } +} + +@container session-lane-header (max-width: 320px) { + .ade-session-lane-header .ade-lane-branch-inline-lane { + display: none; + } +} + +@container chat-composer (min-width: 401px) { + .ade-chat-composer-fast-toggle { + min-width: 2.75rem; + padding-inline: 0.375rem; + } } .ade-work-new-chat-btn { position: relative; + width: var(--ade-work-header-side-h, 36px); + height: var(--ade-work-header-side-h, 36px); + margin-left: 4px; border: none; + border-radius: 6px; background: transparent; color: var(--color-muted-fg); + cursor: pointer; box-shadow: none; transition: color 120ms ease, background 120ms ease; backdrop-filter: none; diff --git a/apps/desktop/src/renderer/lib/laneNavigation.ts b/apps/desktop/src/renderer/lib/laneNavigation.ts new file mode 100644 index 000000000..21dab395e --- /dev/null +++ b/apps/desktop/src/renderer/lib/laneNavigation.ts @@ -0,0 +1,7 @@ +export function openLaneInLanesTabPath(laneId: string): string { + const params = new URLSearchParams({ + laneId, + focus: "single", + }); + return `/lanes?${params.toString()}`; +} diff --git a/apps/desktop/src/renderer/lib/linearIssueWorkNavigation.ts b/apps/desktop/src/renderer/lib/linearIssueWorkNavigation.ts new file mode 100644 index 000000000..f7db4a14a --- /dev/null +++ b/apps/desktop/src/renderer/lib/linearIssueWorkNavigation.ts @@ -0,0 +1,39 @@ +import type { LaneLinearIssue } from "../../shared/types"; + +export type PendingLinearIssueWorkContext = { + laneId: string; + issue: LaneLinearIssue; + contextSource: "lane_link" | "manual"; + modelId: string | null; + requestedAt: number; +}; + +let pending: PendingLinearIssueWorkContext | null = null; + +export function requestLinearIssueWorkContext(args: { + laneId: string; + issue: LaneLinearIssue; + contextSource: "lane_link" | "manual"; + modelId?: string | null; +}): PendingLinearIssueWorkContext { + pending = { + laneId: args.laneId, + issue: args.issue, + contextSource: args.contextSource, + modelId: args.modelId?.trim() || null, + requestedAt: Date.now(), + }; + return pending; +} + +export function peekLinearIssueWorkContext(laneId: string): PendingLinearIssueWorkContext | null { + if (!pending || pending.laneId !== laneId) return null; + return pending; +} + +export function consumeLinearIssueWorkContext(laneId: string): PendingLinearIssueWorkContext | null { + if (!pending || pending.laneId !== laneId) return null; + const next = pending; + pending = null; + return next; +} diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index bf0974e5c..8e2796a1a 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -27,6 +27,7 @@ export function buildAdeCliAgentGuidance(skillRoots: readonly string[] = getAdeA "- If skills are not auto-listed by your runtime, look for them in project/user `.agents/skills`, `.ade/skills`, `.claude/skills`, or ADE's bundled `agent-skills` resources, then read that skill's `SKILL.md` on demand.", `- ${formatAdeAgentSkillRootsForPrompt(skillRoots)}`, "- ADE also sets `ADE_AGENT_SKILLS_DIRS` for ADE-launched CLI sessions when skill roots are known so CLI runtimes can discover the same skills.", + "- When a bundled skill applies *differently* in this project (a missing flag, a port conflict, a required setup step, a workaround for a local quirk), propose appending a one-line note to `/CLAUDE.md` or `/AGENTS.md` — whichever the project already uses — so the next agent picks it up automatically. Propose the edit; do not silently write to user-curated docs. If neither file exists, ask the user which they prefer before creating one.", "", "### Minimum operating rules", "- Start with `ade doctor --text` when the ADE environment is unclear. Use `ade help ` for exact flags and `ade actions list --text` as the escape hatch for service actions without a typed command.", diff --git a/apps/desktop/src/shared/laneColorPalette.ts b/apps/desktop/src/shared/laneColorPalette.ts new file mode 100644 index 000000000..afeaf84b3 --- /dev/null +++ b/apps/desktop/src/shared/laneColorPalette.ts @@ -0,0 +1,67 @@ +export type LaneColor = { + hex: string; + name: string; +}; + +export const LANE_CLASSIC_COLORS: readonly LaneColor[] = [ + { hex: "#a78bfa", name: "Violet" }, + { hex: "#60a5fa", name: "Blue" }, + { hex: "#34d399", name: "Emerald" }, + { hex: "#fbbf24", name: "Amber" }, + { hex: "#f472b6", name: "Pink" }, + { hex: "#fb923c", name: "Orange" }, + { hex: "#2dd4bf", name: "Teal" }, + { hex: "#c084fc", name: "Purple" }, + { hex: "#f87171", name: "Red" }, + { hex: "#a3e635", name: "Lime" }, + { hex: "#22d3ee", name: "Cyan" }, + { hex: "#e879f9", name: "Fuchsia" }, +]; + +export const LANE_RAINBOW_COLORS: readonly LaneColor[] = [ + { hex: "#ef4444", name: "Bright Red" }, + { hex: "#f97316", name: "Bright Orange" }, + { hex: "#facc15", name: "Bright Yellow" }, + { hex: "#22c55e", name: "Bright Green" }, + { hex: "#2563eb", name: "Bright Blue" }, + { hex: "#4f46e5", name: "Indigo" }, + { hex: "#7c3aed", name: "Bright Violet" }, +] as const; + +export const LANE_COLOR_PALETTE: readonly LaneColor[] = [ + ...LANE_CLASSIC_COLORS, + ...LANE_RAINBOW_COLORS, +] as const; + +export const LANE_CLASSIC_COUNT = LANE_CLASSIC_COLORS.length; + +export const LANE_FALLBACK_COLORS: readonly string[] = LANE_COLOR_PALETTE + .slice(0, 8) + .map((entry) => entry.hex); + +export function nextAvailableLaneColor(usedColors: Iterable): string | null { + const used = new Set( + [...usedColors] + .map((color) => color.trim().toLowerCase()) + .filter((color) => color.length > 0), + ); + for (const entry of LANE_COLOR_PALETTE) { + if (!used.has(entry.hex.toLowerCase())) return entry.hex; + } + return null; +} + +export function randomLaneColor(): string { + const index = Math.floor(Math.random() * LANE_COLOR_PALETTE.length); + return LANE_COLOR_PALETTE[index]?.hex ?? LANE_COLOR_PALETTE[0]!.hex; +} + +export function allocateLaneColor(usedColors: Iterable): string { + return nextAvailableLaneColor(usedColors) ?? randomLaneColor(); +} + +export function laneColorName(hex: string | null | undefined): string | null { + if (!hex) return null; + const lower = hex.toLowerCase(); + return LANE_COLOR_PALETTE.find((entry) => entry.hex.toLowerCase() === lower)?.name ?? null; +} diff --git a/apps/desktop/src/shared/types/cto.ts b/apps/desktop/src/shared/types/cto.ts index bf3e4c7b4..d3c378b8c 100644 --- a/apps/desktop/src/shared/types/cto.ts +++ b/apps/desktop/src/shared/types/cto.ts @@ -125,6 +125,8 @@ export type CtoLinearProject = { slug: string; teamName: string; teamKey?: string | null; + icon?: string | null; + color?: string | null; }; export type CtoSearchLinearIssuesArgs = { diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 7eccf4f19..591ed493f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -15,6 +15,13 @@ export default defineConfig({ host: true, port: 5173, strictPort: true, + proxy: { + "/ade-dev-rpc": { + target: "http://127.0.0.1:18765", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/ade-dev-rpc/, ""), + }, + }, fs: { // Keep Vite's default workspace/node_modules access and additionally allow src/. // Monaco's ESM runtime pulls CSS/assets from node_modules at dev-time. diff --git a/scripts/dev-shared.mjs b/scripts/dev-shared.mjs index c2c749d90..9c96ce21b 100644 --- a/scripts/dev-shared.mjs +++ b/scripts/dev-shared.mjs @@ -246,7 +246,7 @@ function readRuntimeInfo(value) { return { version, buildHash, defaultRole, projectRoot }; } -function jsonRpcRequestSequence(socketPath, requests, options = {}) { +export function jsonRpcRequestSequence(socketPath, requests, options = {}) { const timeoutMs = options.timeoutMs ?? 2000; const allowCloseBeforeResponse = options.allowCloseBeforeResponse === true; return new Promise((resolve, reject) => { @@ -335,7 +335,7 @@ function jsonRpcRequestSequence(socketPath, requests, options = {}) { }); } -function jsonRpcRequest(socketPath, method, params, options = {}) { +export function jsonRpcRequest(socketPath, method, params, options = {}) { return jsonRpcRequestSequence(socketPath, [{ method, params }], options); } From 10e44ade62098ab0339a0331e53852a9d9867770 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 26 May 2026 19:01:59 -0400 Subject: [PATCH 2/3] ship: prepare lane for review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop UI cleanup — simplify control flow, DRY repeated patterns, fix Linear addedLabelIds mutation, update tests for new drawer selectors, and align docs with orchestration rename. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/tuiClient/app.tsx | 4 +- apps/desktop/src/main/main.ts | 27 ++------ .../services/ai/tools/orchestrationTools.ts | 1 - .../main/services/chat/agentChatService.ts | 3 +- .../src/main/services/cto/linearClient.ts | 6 +- .../notifications/apnsBridgeService.ts | 56 +++++++--------- .../orchestration/manifestNormalization.ts | 2 - .../orchestration/orchestrationService.ts | 25 +++---- .../components/app/LinearIssueBrowser.tsx | 22 +------ .../src/renderer/components/app/TopBar.tsx | 29 +++------ .../chat/AgentChatPane.submit.test.tsx | 65 ++++++++++--------- .../components/history/CommitHistoryView.tsx | 15 +---- .../components/history/historyGitActions.ts | 14 ++-- .../components/history/historyLaneActions.ts | 18 ++--- .../components/orchestration/PlanMarkdown.tsx | 14 ++-- .../components/orchestration/TaskCard.tsx | 7 -- .../renderer/components/run/RunPage.test.tsx | 2 +- .../components/settings/GitHubSection.tsx | 13 +++- .../terminals/useWorkSessions.test.ts | 5 +- docs/ARCHITECTURE.md | 15 +++-- docs/features/agents/README.md | 2 +- docs/features/chat/README.md | 2 +- docs/features/history/README.md | 2 +- docs/features/lanes/README.md | 2 +- docs/features/linear-integration/README.md | 5 ++ 25 files changed, 152 insertions(+), 204 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 0f88270a9..6d34d7f91 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -345,7 +345,6 @@ export function mergeOptimisticChatSessions( optimisticSessions.delete(sessionId); } const pending = [...optimisticSessions.values()] - .filter((session) => !seen.has(session.sessionId)) .sort((left, right) => { const rightMs = Date.parse(right.lastActivityAt ?? right.startedAt); const leftMs = Date.parse(left.lastActivityAt ?? left.startedAt); @@ -1417,8 +1416,7 @@ export function isPromptWordBackspace(input: string, key: { ctrl?: boolean; meta } export function isPromptLineBackspace(input: string, key: { ctrl?: boolean; meta?: boolean; backspace?: boolean; delete?: boolean }): boolean { - if (isCtrlInput(input, key, "u")) return true; - return false; + return isCtrlInput(input, key, "u"); } type PromptEditResult = { value: string; cursor: number }; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index a156997ac..286ee4c2c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1036,35 +1036,20 @@ app.whenReady().then(async () => { ): RemoteOpenProjectBinding | null => { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; - const targetId = - typeof record.targetId === "string" ? record.targetId.trim() : ""; - const projectId = - typeof record.projectId === "string" ? record.projectId.trim() : ""; - const rootPath = - typeof record.rootPath === "string" ? record.rootPath.trim() : ""; + const targetId = readString(record, "targetId"); + const projectId = readString(record, "projectId"); + const rootPath = readString(record, "rootPath"); if (record.kind !== "remote" || !targetId || !projectId || !rootPath) { return null; } - const runtimeName = - typeof record.runtimeName === "string" && record.runtimeName.trim() - ? record.runtimeName.trim() - : "Remote"; - const displayName = - typeof record.displayName === "string" && record.displayName.trim() - ? record.displayName.trim() - : path.basename(rootPath); - const key = - typeof record.key === "string" && record.key.trim() - ? record.key.trim() - : `remote:${targetId}:${projectId}`; return { kind: "remote", - key, + key: readString(record, "key") ?? `remote:${targetId}:${projectId}`, targetId, - runtimeName, + runtimeName: readString(record, "runtimeName") ?? "Remote", projectId, rootPath, - displayName, + displayName: readString(record, "displayName") ?? path.basename(rootPath), }; }; const savedRemoteProjectBinding = parseSavedRemoteProjectBinding( diff --git a/apps/desktop/src/main/services/ai/tools/orchestrationTools.ts b/apps/desktop/src/main/services/ai/tools/orchestrationTools.ts index edc45bff0..0946643b3 100644 --- a/apps/desktop/src/main/services/ai/tools/orchestrationTools.ts +++ b/apps/desktop/src/main/services/ai/tools/orchestrationTools.ts @@ -162,7 +162,6 @@ const LEAD_READ_ONLY_BASE = new Set([ "summarizeFrontendStructure", ]); - // --------------------------------------------------------------------------- // Tool factories (per orchestration concept) // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index a4df86af7..1f67ce63c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -3895,8 +3895,7 @@ function lockedOrchestrationPermissionMode( // All orchestration roles use full-auto (bypassPermissions). The lead's // security comes from the tool-set restriction (no write tools), not the // permission mode. Plan mode would block orchestration tools. - if (role === "lead" || role === "worker" || role === "validator") return "full-auto"; - return null; + return role ? "full-auto" : null; } function enforceOrchestrationLockedPermissionMode( diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index 882f54894..583796762 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -976,13 +976,13 @@ export function createLinearClient(args: LinearClientArgs) { await request({ query: ` - mutation AddIssueLabel($id: String!, $labelIds: [String!]) { - issueUpdate(id: $id, input: { labelIds: $labelIds }) { + mutation AddIssueLabel($id: String!, $addedLabelIds: [String!]) { + issueUpdate(id: $id, input: { addedLabelIds: $addedLabelIds }) { success } } `, - variables: { id: issueId, labelIds: [labelId] }, + variables: { id: issueId, addedLabelIds: [labelId] }, maxRetries: 1, }); } catch (error) { diff --git a/apps/desktop/src/main/services/notifications/apnsBridgeService.ts b/apps/desktop/src/main/services/notifications/apnsBridgeService.ts index 00f0dfce6..06aa16a23 100644 --- a/apps/desktop/src/main/services/notifications/apnsBridgeService.ts +++ b/apps/desktop/src/main/services/notifications/apnsBridgeService.ts @@ -154,14 +154,18 @@ export function createApnsBridgeService(args: ApnsBridgeServiceArgs) { ? meta.apnsBundleId.trim() : configuredBundleId; if (!deviceBundleId) return { ok: false, reason: "No APNs bundle id found for this device or project." }; - let deviceEnv: "production" | "sandbox"; - if (meta.apnsEnv === "production") { - deviceEnv = "production"; - } else if (meta.apnsEnv === "sandbox") { - deviceEnv = "sandbox"; - } else { - deviceEnv = apnsConfig?.env === "production" ? "production" : "sandbox"; - } + const deviceEnv: "production" | "sandbox" = + meta.apnsEnv === "production" || meta.apnsEnv === "sandbox" + ? meta.apnsEnv + : apnsConfig?.env === "production" ? "production" : "sandbox"; + + const laTopic = `${deviceBundleId}.push-type.liveactivity`; + + const resolveActivityUpdateToken = (): string | null => { + const tokens = Object.values((meta.apnsActivityUpdateTokens ?? {}) as Record) + .filter((token): token is string => typeof token === "string" && token.length > 0); + return tokens[0] ?? null; + }; let deviceToken: string | null; let topic: string; @@ -172,23 +176,19 @@ export function createApnsBridgeService(args: ApnsBridgeServiceArgs) { if (!deviceToken) { return { ok: false, reason: "Device has no Live Activity push-to-start token yet (iOS 17.2+ registers this shortly after launch)." }; } - topic = `${deviceBundleId}.push-type.liveactivity`; + topic = laTopic; pushType = "liveactivity"; payload = buildLiveActivityStartPayload(); } else if (kind === "la_update_running" || kind === "la_update_attention" || kind === "la_update_multi") { - const tokens = Object.values((meta.apnsActivityUpdateTokens ?? {}) as Record) - .filter((token): token is string => typeof token === "string" && token.length > 0); - deviceToken = tokens[0] ?? null; + deviceToken = resolveActivityUpdateToken(); if (!deviceToken) return { ok: false, reason: "No active Live Activity on device to update. Start one first (or fire 'Live Activity - start')." }; - topic = `${deviceBundleId}.push-type.liveactivity`; + topic = laTopic; pushType = "liveactivity"; payload = buildLiveActivityUpdatePayload(kind); } else if (kind === "la_end") { - const tokens = Object.values((meta.apnsActivityUpdateTokens ?? {}) as Record) - .filter((token): token is string => typeof token === "string" && token.length > 0); - deviceToken = tokens[0] ?? null; + deviceToken = resolveActivityUpdateToken(); if (!deviceToken) return { ok: false, reason: "No active Live Activity on device to end." }; - topic = `${deviceBundleId}.push-type.liveactivity`; + topic = laTopic; pushType = "liveactivity"; payload = buildLiveActivityEndPayload(); } else { @@ -317,22 +317,12 @@ function buildLiveActivityStartPayload(): Record { function buildLiveActivityUpdatePayload(kind: "la_update_running" | "la_update_attention" | "la_update_multi"): Record { const nowUnix = Math.floor(Date.now() / 1000); - let variant: "running" | "attention" | "multi"; - let relevanceScore: number; - let alert: { title: string; body: string }; - if (kind === "la_update_attention") { - variant = "attention"; - relevanceScore = 100; - alert = { title: "Claude - Push test", body: "Approval needed - tap Approve/Deny in the island." }; - } else if (kind === "la_update_multi") { - variant = "multi"; - relevanceScore = 60; - alert = { title: "ADE", body: "3 chats running - 1 CI failing - 2 reviews pending" }; - } else { - variant = "running"; - relevanceScore = 40; - alert = { title: "Claude - Push test", body: "Reading src/auth/oauth.ts" }; - } + const variants: Record = { + la_update_attention: { variant: "attention", relevanceScore: 100, alert: { title: "Claude - Push test", body: "Approval needed - tap Approve/Deny in the island." } }, + la_update_multi: { variant: "multi", relevanceScore: 60, alert: { title: "ADE", body: "3 chats running - 1 CI failing - 2 reviews pending" } }, + la_update_running: { variant: "running", relevanceScore: 40, alert: { title: "Claude - Push test", body: "Reading src/auth/oauth.ts" } }, + }; + const { variant, relevanceScore, alert } = variants[kind]; return { aps: { timestamp: nowUnix, diff --git a/apps/desktop/src/main/services/orchestration/manifestNormalization.ts b/apps/desktop/src/main/services/orchestration/manifestNormalization.ts index 5b4bbaa76..1860e1340 100644 --- a/apps/desktop/src/main/services/orchestration/manifestNormalization.ts +++ b/apps/desktop/src/main/services/orchestration/manifestNormalization.ts @@ -460,8 +460,6 @@ export function buildPhaseTransitionOpsAfterTaskRelease( return ops; } -// ManifestPatchOp and OrchestrationTaskStatus imported at top of file - // --------------------------------------------------------------------------- // Supersede inference helpers // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/orchestration/orchestrationService.ts b/apps/desktop/src/main/services/orchestration/orchestrationService.ts index 3aeaed964..fcc885085 100644 --- a/apps/desktop/src/main/services/orchestration/orchestrationService.ts +++ b/apps/desktop/src/main/services/orchestration/orchestrationService.ts @@ -127,20 +127,23 @@ export function createOrchestrationService(deps: OrchestrationServiceDeps) { return now().toISOString(); } - function bundleRootFor(laneId: string, runId: string): string { + function orchestrationDirForLane(laneId: string): string { const worktree = deps.resolveLaneWorktree(laneId); if (!worktree) { throw new Error(`unknown laneId ${laneId} — cannot resolve worktree`); } - return path.join(worktree, ".ade", "orchestration", runId); + return path.join(worktree, ".ade", "orchestration"); + } + + function bundleRootFor(laneId: string, runId: string): string { + return path.join(orchestrationDirForLane(laneId), runId); } async function ensureBundleDir(bundlePath: string): Promise { - await fsp.mkdir(path.join(bundlePath, "artifacts"), { recursive: true }); - await fsp.mkdir(path.join(bundlePath, "artifacts", "ui"), { recursive: true }); - await fsp.mkdir(path.join(bundlePath, "artifacts", "evidence"), { - recursive: true, - }); + await Promise.all([ + fsp.mkdir(path.join(bundlePath, "artifacts", "ui"), { recursive: true }), + fsp.mkdir(path.join(bundlePath, "artifacts", "evidence"), { recursive: true }), + ]); } async function readServerGeneration(bundlePath: string): Promise { @@ -188,14 +191,6 @@ export function createOrchestrationService(deps: OrchestrationServiceDeps) { return mutex; } - function orchestrationDirForLane(laneId: string): string { - const worktree = deps.resolveLaneWorktree(laneId); - if (!worktree) { - throw new Error(`unknown laneId ${laneId} — cannot resolve worktree`); - } - return path.join(worktree, ".ade", "orchestration"); - } - function indexPathForLane(laneId: string): string { return path.join(orchestrationDirForLane(laneId), INDEX_FILE); } diff --git a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx index ebe0484b0..8ccacfe4d 100644 --- a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx +++ b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx @@ -50,7 +50,7 @@ const STATE_TABS = [ ] as const; const ACTIVE_LINEAR_STATE_TYPES = ["backlog", "unstarted", "started"]; -const STATE_GROUP_ORDER = ["started", "unstarted", "backlog", "triage", "completed", "canceled"] as const; +const STATE_GROUP_ORDER = ["started", "unstarted", "backlog", "triage", "completed", "canceled", "duplicate"] as const; const FILTER_STORAGE_PREFIX = "ade.linear.quickView.filters.v1:"; const DEFAULT_FILTERS: LinearIssueBrowserFilters = { @@ -62,17 +62,6 @@ const DEFAULT_FILTERS: LinearIssueBrowserFilters = { sort: "updated_desc", }; -const STATE_LABELS: Record = { - active: "Active", - all: "All states", - backlog: "Backlog", - unstarted: "Todo", - started: "In progress", - completed: "Done", - canceled: "Canceled", - triage: "Triage", -}; - const PRIORITY_OPTIONS = [ { value: "", label: "Any priority" }, { value: "1", label: "Urgent" }, @@ -119,14 +108,7 @@ function safeSaveFilters(projectRoot: string | null | undefined, filters: Linear const key = storageKey(projectRoot); if (!key || typeof window === "undefined") return; try { - if ( - filters.projectId === DEFAULT_FILTERS.projectId && - filters.statePreset === DEFAULT_FILTERS.statePreset && - filters.assigneeId === DEFAULT_FILTERS.assigneeId && - filters.priority === DEFAULT_FILTERS.priority && - filters.query === DEFAULT_FILTERS.query && - filters.sort === DEFAULT_FILTERS.sort - ) { + if (!hasActiveFilters(filters)) { window.localStorage.removeItem(key); return; } diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index d20edb23c..9ad134f51 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -335,28 +335,17 @@ function ShellConnectionChip({ > {layout === "menu-row" ? icon : {label}} {layout === "menu-row" ? ( - <> - {label} - - + {label} ) : ( - <> - {icon} - - + icon )} + ); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 3975d0c6e..9ed2e30b7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -821,8 +821,9 @@ describe("AgentChatPane companion drawers", () => { it("opens the proof drawer and persists split resize from the real divider", async () => { renderDrawerPane(); - fireEvent.click(await screen.findByRole("button", { name: "Open proof drawer" })); - expect(screen.getByText("Artifacts")).toBeTruthy(); + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Proof" })); + expect(screen.getByText("No artifacts captured yet.")).toBeTruthy(); const divider = screen.getByRole("separator", { name: "" }); const splitParent = divider.parentElement; @@ -850,9 +851,9 @@ describe("AgentChatPane companion drawers", () => { expect(window.sessionStorage.getItem("ade.chat.rightPaneSplit")).toBe("40"); }); - fireEvent.click(screen.getByRole("button", { name: "Close proof drawer" })); + fireEvent.click(screen.getByRole("button", { name: "Close chat actions drawer" })); await waitFor(() => { - expect(screen.queryByText("Artifacts")).toBeNull(); + expect(screen.queryByText("No artifacts captured yet.")).toBeNull(); }); }); @@ -1357,7 +1358,7 @@ describe("AgentChatPane submit recovery", () => { ); await screen.findByRole("textbox"); - expect(screen.getByRole("button", { name: /^Terminal$/ })).toBeTruthy(); + expect(await screen.findByRole("button", { name: /(Open|Close) terminal/i })).toBeTruthy(); expect(screen.queryByRole("button", { name: "Open iOS simulator drawer" })).toBeNull(); act(() => { @@ -2153,7 +2154,7 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); const trigger = await screen.findByRole("button", { name: "Claude permission mode" }); - expect(trigger.textContent ?? "").not.toContain("Plan mode"); + expect(trigger.textContent ?? "").not.toContain("plan"); sessions[0] = { ...session, @@ -2176,7 +2177,7 @@ describe("AgentChatPane submit recovery", () => { }); await waitFor(() => { - expect(trigger.textContent ?? "").toContain("Plan mode"); + expect(trigger.textContent ?? "").toContain("Plan"); }); }); @@ -2467,14 +2468,18 @@ describe("AgentChatPane submit recovery", () => { installAdeMocks(); renderPane(session); - expect(await screen.findByRole("button", { name: "Handoff" })).not.toBeNull(); + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Handoff" })); + expect(await screen.findByText("Start a sibling chat on another model")).toBeTruthy(); cleanup(); installAdeMocks(); renderResolverPane(session); + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Handoff" })); await waitFor(() => { - expect(screen.queryByRole("button", { name: "Handoff" })).toBeNull(); + expect(screen.getByText("Handoff is not available for this chat.")).toBeTruthy(); }); }); @@ -2493,9 +2498,9 @@ describe("AgentChatPane submit recovery", () => { , ); - await waitFor(() => { - expect(screen.queryByRole("button", { name: "Handoff" })).toBeNull(); - }); + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Handoff" })); + expect(await screen.findByText("Handoff is not available for this chat.")).toBeTruthy(); }); it("disables chat handoff while the current turn is still active", async () => { @@ -2506,9 +2511,11 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - const button = await screen.findByRole("button", { name: "Handoff" }); + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Handoff" })); + const createBtn = await screen.findByRole("button", { name: "Create handoff chat" }); await waitFor(() => { - expect((button as HTMLButtonElement).disabled).toBe(true); + expect((createBtn as HTMLButtonElement).disabled).toBe(true); }); }); @@ -2534,9 +2541,8 @@ describe("AgentChatPane submit recovery", () => { , ); - const handoffBtn = await screen.findByRole("button", { name: "Handoff" }) as HTMLButtonElement; - await waitFor(() => expect(handoffBtn.disabled).toBe(false)); - fireEvent.click(handoffBtn); + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Handoff" })); expect(await screen.findByText("Create opens the new work chat and sends the handoff summary as its first message.")).toBeTruthy(); fireEvent.click(await screen.findByRole("button", { name: "Create handoff chat" })); @@ -2578,11 +2584,11 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - const handoffBtn = await screen.findByRole("button", { name: "Handoff" }) as HTMLButtonElement; - await waitFor(() => expect(handoffBtn.disabled).toBe(false)); - fireEvent.click(handoffBtn); + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Handoff" })); - const handoffMenu = (await screen.findByText("Start a sibling chat on another model")).closest("[data-chat-handoff-menu='true']"); + const handoffTextEl = await screen.findByText("Start a sibling chat on another model"); + const handoffMenu = handoffTextEl.parentElement!.parentElement!; expect(handoffMenu).toBeTruthy(); fireEvent.click(within(handoffMenu as HTMLElement).getByRole("button", { name: /^Select model/ })); const claudeLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; @@ -2622,11 +2628,11 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - const handoffBtn = await screen.findByRole("button", { name: "Handoff" }) as HTMLButtonElement; - await waitFor(() => expect(handoffBtn.disabled).toBe(false)); - fireEvent.click(handoffBtn); + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Handoff" })); - const handoffMenu = (await screen.findByText("Start a sibling chat on another model")).closest("[data-chat-handoff-menu='true']"); + const handoffTextEl2 = await screen.findByText("Start a sibling chat on another model"); + const handoffMenu = handoffTextEl2.parentElement!.parentElement!; expect(handoffMenu).toBeTruthy(); fireEvent.click(within(handoffMenu as HTMLElement).getByRole("button", { name: /^Select model/ })); const claudeLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; @@ -2934,7 +2940,7 @@ describe("AgentChatPane submit recovery", () => { await waitFor(() => { expect(suggestLaneName).toHaveBeenCalled(); expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(true); - expect((screen.getByRole("button", { name: "Launch in background" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Launching" }) as HTMLButtonElement).disabled).toBe(true); }); expect((textbox as HTMLTextAreaElement).disabled).toBe(false); @@ -3536,9 +3542,8 @@ describe("AgentChatPane submit recovery", () => { , ); - // The git toolbar renders commit/push buttons when laneId is present - expect(await screen.findByText("Stage & Commit")).toBeTruthy(); - expect(screen.getByText("Push")).toBeTruthy(); + // The git toolbar renders a PR button when laneId is present + expect(await screen.findByText("PR")).toBeTruthy(); }); it("labels a merged linked PR in the git toolbar", async () => { @@ -3581,7 +3586,7 @@ describe("AgentChatPane submit recovery", () => { // Wait for the pane to fully render — no git toolbar when laneId is null await waitFor(() => { - expect(screen.queryByText("Commit")).toBeNull(); + expect(screen.queryByText("PR")).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/history/CommitHistoryView.tsx b/apps/desktop/src/renderer/components/history/CommitHistoryView.tsx index 231a7d0ee..516b81765 100644 --- a/apps/desktop/src/renderer/components/history/CommitHistoryView.tsx +++ b/apps/desktop/src/renderer/components/history/CommitHistoryView.tsx @@ -287,24 +287,15 @@ export function CommitHistoryView({ if (!node) return null; const cx = columnCenterX(node.column); const cy = rowCenterY(node.rowIndex); - const fill = node.isHead - ? "#22C55E" - : node.isMerge - ? "#3B82F6" - : "var(--color-card)"; - const stroke = node.isHead - ? "#22C55E" - : node.isMerge - ? "#3B82F6" - : "rgba(255,255,255,0.35)"; + const color = node.isHead ? "#22C55E" : node.isMerge ? "#3B82F6" : null; return ( ); diff --git a/apps/desktop/src/renderer/components/history/historyGitActions.ts b/apps/desktop/src/renderer/components/history/historyGitActions.ts index e0c247336..ee6fb67de 100644 --- a/apps/desktop/src/renderer/components/history/historyGitActions.ts +++ b/apps/desktop/src/renderer/components/history/historyGitActions.ts @@ -397,14 +397,12 @@ export async function runHistoryGitAction(args: { case "reset_soft": case "reset_mixed": case "reset_hard": { - const mode = - actionId === "reset_soft" ? "soft" : actionId === "reset_mixed" ? "mixed" : "hard"; - const detail = - mode === "hard" - ? " This discards uncommitted worktree changes." - : mode === "mixed" - ? " This keeps file changes but unstages them." - : " This keeps changes staged."; + const resetModes = { + reset_soft: { mode: "soft" as const, detail: " This keeps changes staged." }, + reset_mixed: { mode: "mixed" as const, detail: " This keeps file changes but unstages them." }, + reset_hard: { mode: "hard" as const, detail: " This discards uncommitted worktree changes." }, + }; + const { mode, detail } = resetModes[actionId]; if (!window.confirm(`Reset this lane to ${commit.shortSha}?${detail}`)) return; await window.ade.git.resetToCommit({ laneId, commitSha: commit.sha, mode }); onNotice?.(`Reset lane to ${commit.shortSha}`); diff --git a/apps/desktop/src/renderer/components/history/historyLaneActions.ts b/apps/desktop/src/renderer/components/history/historyLaneActions.ts index 70156dbd3..67068b23a 100644 --- a/apps/desktop/src/renderer/components/history/historyLaneActions.ts +++ b/apps/desktop/src/renderer/components/history/historyLaneActions.ts @@ -122,16 +122,18 @@ export function buildHistoryLaneActions(args: { if (args.conflictState?.inProgress) { const isRebase = args.conflictState.kind === "rebase"; const isMerge = args.conflictState.kind === "merge"; - const continueReason = args.conflictState.conflictedFiles.length - ? "Resolve conflicted files first" - : undefined; + const conflictCount = args.conflictState.conflictedFiles.length; + const continueReason = conflictCount ? "Resolve conflicted files first" : undefined; + const conflictDescription = (kind: string) => + conflictCount + ? `${conflictCount} conflicted file${conflictCount === 1 ? "" : "s"} left` + : `Resume the interrupted ${kind}`; + conflictActions.push( { id: "rebase_continue", label: "Continue rebase", - description: args.conflictState.conflictedFiles.length - ? `${args.conflictState.conflictedFiles.length} conflicted file${args.conflictState.conflictedFiles.length === 1 ? "" : "s"} left` - : "Resume the interrupted rebase", + description: conflictDescription("rebase"), disabled: disabled || !isRebase || !args.conflictState.canContinue, disabledReason: disabledReason ?? (!isRebase ? "No rebase in progress" : continueReason), }, @@ -146,9 +148,7 @@ export function buildHistoryLaneActions(args: { { id: "merge_continue", label: "Continue merge", - description: args.conflictState.conflictedFiles.length - ? `${args.conflictState.conflictedFiles.length} conflicted file${args.conflictState.conflictedFiles.length === 1 ? "" : "s"} left` - : "Resume the interrupted merge", + description: conflictDescription("merge"), disabled: disabled || !isMerge || !args.conflictState.canContinue, disabledReason: disabledReason ?? (!isMerge ? "No merge in progress" : continueReason), }, diff --git a/apps/desktop/src/renderer/components/orchestration/PlanMarkdown.tsx b/apps/desktop/src/renderer/components/orchestration/PlanMarkdown.tsx index 6521b45a6..e77bd3fce 100644 --- a/apps/desktop/src/renderer/components/orchestration/PlanMarkdown.tsx +++ b/apps/desktop/src/renderer/components/orchestration/PlanMarkdown.tsx @@ -58,6 +58,13 @@ export type PlanMarkdownProps = { className?: string; }; +const headingAttrs = Object.fromEntries( + ["h1", "h2", "h3", "h4", "h5", "h6"].map((tag) => [ + tag, + Array.from(new Set([...(defaultSchema.attributes?.[tag] ?? []), "id", "dataSectionId"])), + ]), +); + const planSanitizeSchema: RehypeSanitizeOptions = { ...defaultSchema, tagNames: Array.from(new Set([...(defaultSchema.tagNames ?? []), "a", "sub", "details", "summary"])), @@ -65,12 +72,7 @@ const planSanitizeSchema: RehypeSanitizeOptions = { ...(defaultSchema.attributes ?? {}), "*": Array.from(new Set([...(defaultSchema.attributes?.["*"] ?? []), "id", "title"])), a: Array.from(new Set([...(defaultSchema.attributes?.a ?? []), "href", "id", "title"])), - h1: Array.from(new Set([...(defaultSchema.attributes?.h1 ?? []), "id", "dataSectionId"])), - h2: Array.from(new Set([...(defaultSchema.attributes?.h2 ?? []), "id", "dataSectionId"])), - h3: Array.from(new Set([...(defaultSchema.attributes?.h3 ?? []), "id", "dataSectionId"])), - h4: Array.from(new Set([...(defaultSchema.attributes?.h4 ?? []), "id", "dataSectionId"])), - h5: Array.from(new Set([...(defaultSchema.attributes?.h5 ?? []), "id", "dataSectionId"])), - h6: Array.from(new Set([...(defaultSchema.attributes?.h6 ?? []), "id", "dataSectionId"])), + ...headingAttrs, }, clobber: (defaultSchema.clobber ?? []).filter((name) => name !== "id"), }; diff --git a/apps/desktop/src/renderer/components/orchestration/TaskCard.tsx b/apps/desktop/src/renderer/components/orchestration/TaskCard.tsx index 9fc95b4a5..948407d76 100644 --- a/apps/desktop/src/renderer/components/orchestration/TaskCard.tsx +++ b/apps/desktop/src/renderer/components/orchestration/TaskCard.tsx @@ -7,7 +7,6 @@ */ import { - type CSSProperties, useEffect, useMemo, useState, @@ -160,12 +159,6 @@ export function TaskCard({ "mt-1 font-sans text-[12px] leading-[1.6] text-fg/60", expanded ? undefined : "line-clamp-3", )} - style={{ - display: expanded ? undefined : "-webkit-box", - WebkitLineClamp: expanded ? undefined : 3, - WebkitBoxOrient: expanded ? undefined : "vertical", - overflow: expanded ? undefined : "hidden", - } as CSSProperties} > {task.description}
diff --git a/apps/desktop/src/renderer/components/run/RunPage.test.tsx b/apps/desktop/src/renderer/components/run/RunPage.test.tsx index 7d7152e0b..698cbb849 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.test.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.test.tsx @@ -316,7 +316,7 @@ describe("RunPage Advanced lane runtime drawer", () => { it("opens the terminal drawer without creating a shell from the plain toggle", async () => { render(); - fireEvent.click(screen.getByRole("button", { name: /^terminal$/i })); + fireEvent.click(screen.getByRole("button", { name: /open terminal/i })); expect(await screen.findByText("Open a shell or run a command to attach a terminal.")).toBeTruthy(); expect(vi.mocked((window as unknown as { ade: { pty: { create: ReturnType } } }).ade.pty.create)).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx index 47fbc8433..6f5dd0957 100644 --- a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx @@ -41,6 +41,17 @@ function detectTokenType(token: string): TokenType { return "unknown"; } +function tokenTypeDetectionLabel(type: TokenType): string { + switch (type) { + case "classic": + return "Classic token"; + case "fine-grained": + return "Fine-grained token"; + default: + return "Unknown format"; + } +} + function authSourceLabel(status: GitHubStatus | null): string { switch (status?.authSource) { case "gh": @@ -519,7 +530,7 @@ export function GitHubSection() { /> {githubTokenDraft.trim() ? ( - Detected: {detectTokenType(githubTokenDraft.trim()) === "classic" ? "Classic token" : detectTokenType(githubTokenDraft.trim()) === "fine-grained" ? "Fine-grained token" : "Unknown format"} + Detected: {tokenTypeDetectionLabel(detectTokenType(githubTokenDraft.trim()))} ) : null} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 97c395eab..10276dddb 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -1074,9 +1074,10 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { const { result } = renderHook(() => useWorkSessions()); await waitFor(() => { - expect(result.current.filtered.map((session) => session.id)).toEqual(["session-running"]); + expect(result.current.filtered.map((session) => session.id)).toEqual(["session-running", "session-ended"]); }); - expect(result.current.endedFiltered).toEqual([]); + expect(result.current.runningFiltered.map((s) => s.id)).toEqual(["session-running"]); + expect(result.current.endedFiltered.map((s) => s.id)).toEqual(["session-ended"]); }); it("reapplies the same stale-session URL filters after navigating to a valid session and back", async () => { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca6bf2f05..6ec857119 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ Consolidated technical reference for the ADE (Agentic Development Environment) s ADE is a local-first development control plane that orchestrates AI-assisted software engineering across parallel worktrees. The center of the system is a **per-machine ADE runtime daemon** (`apps/ade-cli/`, started with `ade serve`). The daemon hosts every project on that machine through a project registry and exposes a multi-project JSON-RPC surface on a Unix socket / Windows named pipe at `~/.ade/sock/ade.sock`. Desktop, the terminal `ade code` client, the iOS app, and SSH-attached desktop windows are all peer **clients** that bind to a runtime — local or remote — and invoke runtime-owned actions through that one surface. -The runtime owns everything that needs to survive a client closing: worktree-per-lane git isolation, a multi-provider AI runtime, a Linear-integrated CTO agent acting as a team lead, worker delegation, a pipeline builder for visual automations, stacked pull requests with conflict simulation, computer-use proofs, the sync host that replicates projects to other devices, and the per-machine credential store and agent registry. Nothing leaves the user's machine by default: AI work runs through user-authenticated CLIs (Claude Code, Codex), local API-key routes (OpenCode server), or local model endpoints (Ollama, LM Studio, vLLM). +The runtime owns everything that needs to survive a client closing: worktree-per-lane git isolation, a multi-provider AI runtime, a work-tab orchestration layer for multi-phase plans, a Linear-integrated CTO agent acting as a team lead, worker delegation, a pipeline builder for visual automations, stacked pull requests with conflict simulation, computer-use proofs, the sync host that replicates projects to other devices, and the per-machine credential store and agent registry. Nothing leaves the user's machine by default: AI work runs through user-authenticated CLIs (Claude Code, Codex), local API-key routes (OpenCode server), or local model endpoints (Ollama, LM Studio, vLLM). ADE ships as four runtime/client packages plus the marketing site: @@ -278,7 +278,7 @@ Roles are open-ended strings; today's vocabulary is `desktop-main`, `ade-serve-d - Schema is defined idempotently — `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`. - One-time schema-compat migration at startup: retrofits `NOT NULL` on PKs and strips UNIQUE/FK constraints incompatible with cr-sqlite CRRs. A pre-cr-sqlite backup (`.pre-crsqlite-w1.bak`) is written on first CRR enablement. - Feature migrations add columns via `ALTER TABLE ADD COLUMN`, wrapped by `crsql_begin_alter`/`crsql_commit_alter` to stay CRR-safe. -- Targeted per-domain migrations live in `kvDb.migrations.test.ts`, which covers the consolidated upgrade path for mission/orchestrator/worker tables plus later CRR-safe schema cleanups. +- Targeted per-domain migrations live in `kvDb.migrations.test.ts`, which covers the consolidated upgrade path for orchestration/worker tables plus later CRR-safe schema cleanups. - The canonical iOS bootstrap schema is exported from desktop `kvDb.ts` to `apps/ios/ADE/Resources/DatabaseBootstrap.sql` so iOS stays schema-compatible. --- @@ -397,6 +397,11 @@ ade.ai.* # AI integration status + provider auth (storeApiKe # used to gate the ModelPicker OpenCode rail + Settings install CTA. ade.ai.cursorCloud.* # Cursor background-agents bridge: listRepositories, listAgents, listRuns, getAgent, createRun, followUp, streamRun, cancelRun, archiveAgent / unarchiveAgent / deleteAgent, listArtifacts / downloadArtifact, openChat (mirror an existing cloud agent into an ADE chat session) ade.automations.* +ade.orchestration.* # work-tab orchestration: runCreate, bundleRead, manifestReadSection, + # manifestPatch, planAppend, planWrite, spawnAgent, agentInject, + # assetRegister, claimTask, subscribe (push). Preload bridge in + # preload/orchestrationBridge.ts; renderer consumes via + # components/orchestration/orchestrationDataSource.ts. ade.processes.* / ade.tests.* # processes also expose group bulk ops: # ade.processes.startGroup / stopGroup / restartGroup ade.config.* # project config get/save/trust @@ -457,7 +462,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `appControl/` | `appControlService.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). | | `builtInBrowser/` | `builtInBrowserService.ts` | In-app web browser owned by the main process. Allocates `WebContentsView` tabs against the shared `persist:ade-browser` partition (cap 10), positions them over a renderer-supplied bounds rect, drives navigation / tabs / reload / back / forward, attaches the Chrome DevTools Protocol debugger for inspect-mode hit tests, captures screenshots, and emits `BuiltInBrowserEventPayload`s to subscribers. Consumed by `ChatBuiltInBrowserPanel` (Work sidebar Browser tab) and by `openUrlInAdeBrowser()` in the renderer so renderer-side link clicks open inside ADE rather than the system browser. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | -| `chat/` | `agentChatService.ts`, `runtimeEvents.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`), `sessionRecovery.ts` | Agent chat sessions (lane-scoped + mission worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool, formalizes the cross-runtime event vocabulary, discovers and resolves provider-specific slash commands through a shared markdown engine, recovers sessions on restart, and derives prompt-based lane names for parallel model launches. | +| `chat/` | `agentChatService.ts`, `runtimeEvents.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`), `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool, formalizes the cross-runtime event vocabulary, discovers and resolves provider-specific slash commands through a shared markdown engine, recovers sessions on restart, and derives prompt-based lane names for parallel model launches. | | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `agentBrowserArtifactAdapter.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, agent-browser payload parser, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | | `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. | @@ -480,6 +485,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `macosVm/` | `macosVmService.ts`, `rfbDirectClient.ts`, `credentialsStore.ts`, `runtimeBootstrap.ts`, `macosVmRecovery.ts` | Lane-tied macOS VM lifecycle and GUI control. `macosVmService.ts` uses Lume, stores VM records in `.ade/cache`, mounts direct lane roots when safe (otherwise a sanitized rsync mirror), and exposes screenshot/click/type/select through headless VNC or visible-window fallbacks. `credentialsStore.ts` keeps guest user credentials in the macOS Keychain (`/usr/bin/security`, service `ade-macos-vm-` / account `ade-cli`); renderers only see a summary. `runtimeBootstrap.ts` installs the in-guest ade-runtime over SSH+SCP with a five-phase progress signal (`ssh-probe`, `write-script`, `scp-script`, `run-script`, `verify-marker`). `macosVmRecovery.ts` is a standalone CLI cleanup path for stale records / lease / VNC credentials when the desktop surface cannot reach them. | | `onboarding/` | `onboardingService.ts` | First-run flow, defaults detection, existing lane discovery. | | `opencode/` | `openCodeRuntime.ts`, `openCodeServerManager.ts`, `openCodeBinaryManager.ts`, `openCodeInventory.ts`, `openCodeModelCatalog.ts` | OpenCode server spawn, binary resolution, model discovery. | +| `orchestration/` | `orchestrationService.ts`, `applyPatches.ts`, `patchPolicy.ts`, `manifestNormalization.ts`, `runtimeProfile.ts` | Work-tab orchestration for multi-phase plans. `orchestrationService` manages run lifecycle, manifest persistence, plan markdown, and asset bundles. `applyPatches` + `patchPolicy` handle lead/worker manifest patches with safety constraints. `runtimeProfile` resolves the active orchestration profile per session. The renderer surfaces live in `renderer/components/orchestration/` (see §7.3). The former `orchestrator/` and `missions/` directories were consolidated into this service. | | `processes/` | `processService.ts` | Managed-process lifecycle per lane, readiness probes, restart policies. | | `projects/` | `adeProjectService.ts`, `configReloadService.ts`, `projectService.ts`, `logIntegrityService.ts`, `recentProjectSummary.ts`, `projectBrowserService.ts`, `projectDetailService.ts` | Project detection + `.ade` repair/bootstrap, reload on config change, recent-project metadata. `projectBrowserService` is the in-app directory autocomplete used by the Command Palette project browser (typed-path completion, `.git` detection, home expansion, system-picker fallback); `projectDetailService` returns repo metadata (branch, dirty count, ahead/behind, last commit, README excerpt, language mix, lane count, last-opened) for the palette's preview pane. | | `prs/` | `prService.ts`, `prPollingService.ts`, `prSummaryService.ts`, `queueLandingService.ts`, `issueInventoryService.ts`, `prIssueResolver.ts`, `prRebaseResolver.ts`, `integrationPlanning.ts`, `integrationValidation.ts` | PR CRUD, polling (with per-PR `last_polled_at` cursor), AI summary cache keyed by `(prId, head_sha)`, stacked-queue landing, issue inventory, AI-assisted resolution, integration planning, and merge-into-existing-lane proposal adoption. | @@ -549,7 +555,7 @@ Domain stores co-located with their pages follow the same factory + context patt Feature-grouped under `apps/desktop/src/renderer/components/`: ``` -app/ # shell, App.tsx, TopBar, TabNav, startup, splash +app/ # shell, App.tsx, TopBar, TabNav, LinearIssueBrowser, LinearIssueResolveModals, startup, splash project/ # Play tab, run/test/process controls lanes/ # list/detail/inspector, stacks, laneDesignTokens.ts files/ # tree, editor, diffs @@ -561,6 +567,7 @@ prs/ # PR list/detail, stacked queue, shared/ history/ # operation timeline automations/ # rule list, pipeline builder cto/ # CTO page, identity editor, team panel, pipeline, shared/designTokens.ts +orchestration/ # OrchestrationPanel, TaskCard, PlanMarkdown, PhaseAccordion, AnnotationPopover, SpecPreviewCard onboarding/ # first-run flows settings/ # keybindings, agents, data, context, sync chat/ # AgentChatPane + composer + subpanels diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 240ced62f..f058d5d4d 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -21,7 +21,7 @@ those surfaces. | `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser (`browser panel`, `browser open [--no-panel]`, `browser new-tab --background`, `browser switch`, `browser close`, plus selection / inspect commands). `ade chat create --provider codex --model --fast` opts a new Codex session into the fast service tier; `ade shell start --lane --chat-session ` (or `ADE_CHAT_SESSION_ID` from the env) attaches a tracked shell to an existing chat so `ade --socket terminal read --chat-session "$ADE_CHAT_SESSION_ID" --text` resolves to it. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links one or more Linear issues to an existing lane with optional `--role`, `--source`, `--include-in-pr`/`--no-include-in-pr`, and `--close-on-merge` flags. | | `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for worker CLI launches, and returns GitHub + ADE PR URLs from PR creation tools when available. | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side install / status / uninstall surface for the `ade` launcher. Owns the install-target path resolution and the optional shell-rc PATH append. | -| `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, naming the bundled ADE skills, using socket-backed live surfaces, registering proof, and cleaning up started processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, CTO/mission workers, and mobile-started runtime work. | +| `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, naming the bundled ADE skills, using socket-backed live surfaces, registering proof, and cleaning up started processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, CTO/worker agents, and mobile-started runtime work. | | `apps/desktop/src/shared/agentSkillRoots.ts` | Resolves and formats Agent Skill roots injected into prompts and CLI environments: lane/current-working-directory ancestors, user homes, inherited `ADE_AGENT_SKILLS_DIRS`, packaged ADE resources, and source fallbacks across `.cursor`, `.claude`, `.agents`, `.ade`, and `.codex` skill directories. | | `apps/desktop/src/shared/ctoPersonalityPresets.ts` | CTO personality overlays. | | `apps/desktop/src/shared/types/agents.ts` | Worker identity, role, adapter, and runtime types. | diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index a6946d5c5..5ca93e2d3 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -11,7 +11,7 @@ machinery layered on top. | Path | Role | |---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Lane naming runs through the session-intelligence prompt path, retries the requested/configured/default title models, then falls back to a prompt slug with an optional temporary suffix for uniqueness. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Large orchestrator file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Lane naming runs through the session-intelligence prompt path, retries the requested/configured/default title models, then falls back to a prompt slug with an optional temporary suffix for uniqueness. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Large service file. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | | `apps/ade-cli/src/tuiClient/` | Terminal **Work** chat TUI (Ink + React): same action/RPC contracts as desktop, **attached** (socket) or **embedded** (headless runtime via `ade-cli`). See [ADE Code](../ade-code/README.md). | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Window-open requests from a page are handled via `setWindowOpenHandler` returning `action: "allow"` + a `createWindow` factory: a new internal tab is created and its `webContents` is returned to Chromium so the popup keeps its real `window.opener` relationship with the opener tab (important for OAuth flows that postMessage back to the parent). The browser session runs with the standard Electron Chrome UA — no header rewriting — but its permission handlers allow a narrow set of Google sign-in capabilities (`hid`, `serial`, `usb`, `storage-access`, `top-level-storage-access`) for requests that originate at `accounts.google.com` so the Google flow's WebAuthn / storage-access probes do not bounce. All other permissions are denied. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | diff --git a/docs/features/history/README.md b/docs/features/history/README.md index b1631826b..1ba9cbbc3 100644 --- a/docs/features/history/README.md +++ b/docs/features/history/README.md @@ -47,7 +47,7 @@ Main process / runtime services: | Path | Role | |---|---| | `apps/desktop/src/main/services/history/operationService.ts` | CRUD for `operations` rows; the canonical entry point for `record`, `start`, `finish`, `list`, `get`, and `listHeadChanges`. Same source backs the runtime daemon and the desktop fallback path. | -| `apps/desktop/src/main/services/state/kvDb.ts` | Schema for `operations`, `checkpoints`, `pack_events`, `pack_versions`, `pack_heads`, `terminal_sessions`, `orchestrator_chat_threads`, `orchestrator_chat_messages`. | +| `apps/desktop/src/main/services/state/kvDb.ts` | Schema for `operations`, `checkpoints`, `pack_events`, `pack_versions`, `pack_heads`, `terminal_sessions`, and orchestration-related tables. | | `apps/desktop/src/main/services/git/gitOperationsService.ts` | Brackets every git operation with `operationService.start` / `finish`, captures pre/post HEAD SHAs, and owns the per-lane undo/redo head-change pipeline (`undoLastHeadChange`, `redoLastHeadChange`, `createTag`, `resetToCommit`, `pull` with `ff-only` / `rebase` / `merge` modes). Undo selection is branch-aware: it ignores checkout/undo rows, requires the recorded operation's `metadata.branchRef` to match the lane's current branch, and rechecks the branch before running `reset --hard`. | | `apps/desktop/src/main/services/lanes/laneService.ts` | Lane CRUD now accepts `CreateLaneArgs.startPoint`, used by the Commits view's "Create lane here" affordance to fork a new lane from a specific commit. | | `apps/desktop/src/main/services/prs/prService.ts` | Records PR creation as an operation. | diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 71ee0999f..e76decfc6 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -59,7 +59,7 @@ Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| -| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, mission role tagging, startup repair routines, branch switching, macOS VM placement wiring, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. macOS VM placement links the lane to the current VM, rolls placement back on critical link failure, starts mirror sync best-effort, and emits a placement-change event. | +| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, macOS VM placement wiring, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. macOS VM placement links the lane to the current VM, rolls placement back on critical link failure, starts mirror sync best-effort, and emits a placement-change event. | | `autoRebaseService.ts` | Auto-rebase worker for stacked lanes, attention state, head-change handlers. Consults `resolvePrRebaseMode` to determine whether a lane with a linked PR should auto-rebase (`pr_target` strategy) or only surface manual attention (`lane_base` strategy). `listStatuses({ includeAll: true })` returns stored statuses without recomputing lane git status for PR workflow views. | | `rebaseSuggestionService.ts` | Emits rebase suggestions when a parent lane advances, dismiss/defer lifecycle. Each suggestion may include up to 20 `RebaseTargetCommit` entries showing the behind commits the rebase would pull in. | | `laneEnvironmentService.ts` | Environment init pipeline: env files, docker services, dependencies, mount points, copy paths (Phase 5 W1) | diff --git a/docs/features/linear-integration/README.md b/docs/features/linear-integration/README.md index 47a237583..81b420fcf 100644 --- a/docs/features/linear-integration/README.md +++ b/docs/features/linear-integration/README.md @@ -273,6 +273,11 @@ Renderer wiring: for the workspace summary and `ade.cto.searchLinearIssues` for paginated results. Persists per-project filter state in `localStorage` under `ade.linear.quickView.filters.v1:`. +- `apps/desktop/src/renderer/components/app/LinearIssueResolveModals.tsx` + — modal dialogs for resolving Linear issues directly from the + quick-view or issue browser: create a new lane from the issue, + attach to an existing lane, or launch an agent chat to work on the + issue with model selection and branch preview. - `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` — the composer's Linear attach affordance opens a `LinearIssueContextDialog` that hosts the same From 6e1f8d8b425be9b2542d2e333edda1dd3adc1ff1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 26 May 2026 19:56:07 -0400 Subject: [PATCH 3/3] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20fix=20t?= =?UTF-8?q?est-desktop=20(2),=20address=20#3307474740?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove test for deliberately-removed Refresh button in UsageQuotaPanel - Add 60s TTL to pending Linear issue work context to prevent stale consumption Co-Authored-By: Claude Opus 4.7 (1M context) --- .../renderer/components/usage/usage.test.tsx | 18 +------------ .../renderer/lib/linearIssueWorkNavigation.ts | 26 +++++++++++++++---- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/renderer/components/usage/usage.test.tsx b/apps/desktop/src/renderer/components/usage/usage.test.tsx index 0fa41e360..af80e72d6 100644 --- a/apps/desktop/src/renderer/components/usage/usage.test.tsx +++ b/apps/desktop/src/renderer/components/usage/usage.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import React from "react"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AiProviderConnectionStatus, @@ -233,22 +233,6 @@ describe("usage components", () => { }); }); - it("keeps live provider polling available through the manual refresh button", async () => { - render(); - - await waitFor(() => { - const refreshButton = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement; - expect(refreshButton.disabled).toBe(false); - }); - - const baseline = vi.mocked(window.ade.usage.refresh).mock.calls.length; - fireEvent.click(screen.getByRole("button", { name: /refresh/i })); - - await waitFor(() => { - expect(window.ade.usage.refresh).toHaveBeenCalledTimes(baseline + 1); - }); - }); - it("hides providers whose CLI is not detected on this machine", async () => { vi.mocked(window.ade.ai.getStatus).mockResolvedValue( makeAiStatus({ diff --git a/apps/desktop/src/renderer/lib/linearIssueWorkNavigation.ts b/apps/desktop/src/renderer/lib/linearIssueWorkNavigation.ts index f7db4a14a..c980db0d2 100644 --- a/apps/desktop/src/renderer/lib/linearIssueWorkNavigation.ts +++ b/apps/desktop/src/renderer/lib/linearIssueWorkNavigation.ts @@ -8,8 +8,23 @@ export type PendingLinearIssueWorkContext = { requestedAt: number; }; +/** Pending context expires after 60 seconds to prevent stale consumption. */ +const PENDING_TTL_MS = 60_000; + let pending: PendingLinearIssueWorkContext | null = null; +function isExpired(ctx: PendingLinearIssueWorkContext): boolean { + return Date.now() - ctx.requestedAt > PENDING_TTL_MS; +} + +/** Discard stale pending context and return null when expired. */ +function getIfFresh(): PendingLinearIssueWorkContext | null { + if (pending && isExpired(pending)) { + pending = null; + } + return pending; +} + export function requestLinearIssueWorkContext(args: { laneId: string; issue: LaneLinearIssue; @@ -27,13 +42,14 @@ export function requestLinearIssueWorkContext(args: { } export function peekLinearIssueWorkContext(laneId: string): PendingLinearIssueWorkContext | null { - if (!pending || pending.laneId !== laneId) return null; - return pending; + const ctx = getIfFresh(); + if (!ctx || ctx.laneId !== laneId) return null; + return ctx; } export function consumeLinearIssueWorkContext(laneId: string): PendingLinearIssueWorkContext | null { - if (!pending || pending.laneId !== laneId) return null; - const next = pending; + const ctx = getIfFresh(); + if (!ctx || ctx.laneId !== laneId) return null; pending = null; - return next; + return ctx; }