From 7be5e6054095d0d39ba8c6b85e3063279de4ffab Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 5 Jun 2026 04:38:30 -0400 Subject: [PATCH 1/7] Work-tab chat UI overhaul: Cursor-style grid, cleaner panes, animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major overhaul of the Work-tab chat surface across all runtimes. Layout & navigation - Removed the entire tabs/grid top bar and the viewMode concept app-wide; WorkViewArea shrank ~2278→1207 lines (legacy preset grid, tab strip, WorkGlassHeader, ArrangeMenu, ViewModeToggle all deleted). - Per-surface header now owns the sessions toggle (far left) + Tools toggle (far right, purple glyph) for both chat and CLI. - New Cursor-style drag grid: drag a session card from the sidebar onto a chat/CLI to split at the hovered edge; resize, rearrange, drag-out to single view, right-click remove-from-grid; multiple grid sets; sidebar grid badge (active set highlighted). New WorkGridView + lib/workGrid + grid-set state. Panes - Chat-actions + PR floating panes: neutral sidebar-colored background (no purple), compact, smaller width, chat recenters on normal screens. - Chat-actions tabs restyled neutral with an animated sliding violet underline (GlowMenu neutral mode); Run moved to a 4th tab rendered inline. - Subagent cards redesigned: compact rows with per-agent geometric glyphs (no childish purple boxes). Subagent takeover only when there's content. - New inline PR creator in the PR pane (no leaving the Work tab). Transcript & composer - Unbubbled assistant text, flat lighter canvas (#0f0f11), 14px prose, no scrollbar, clean turn dividers (time · worked-for), interrupted text-only. - Removed composer BorderBeam + white border; relocated clipboard notice. - Nuked the Codex "Open in CLI" button + its entire cross-process plumbing. Animations - Smooth lane-collapse, floating-pane fades, Tools-sidebar fade, iMessage-style message send-up, and a blur-dissolve when the new-chat pane becomes the chat. Gates: typecheck PASS, lint PASS (warnings only), desktop build PASS. Known: 11 renderer tests assert pre-overhaul UI text (turn-divider rollup, draft-launch banner wording) — assertion drift to reconcile in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/services/adeActions/registry.test.ts | 1 - .../src/main/services/adeActions/registry.ts | 44 - .../main/services/chat/agentChatService.ts | 20 - .../services/chat/codexCliLauncher.test.ts | 229 --- .../main/services/chat/codexCliLauncher.ts | 195 --- .../src/main/services/ipc/registerIpc.ts | 54 - .../main/services/ipc/runtimeBridge.test.ts | 4 +- .../remoteConnectionPool.test.ts | 8 +- apps/desktop/src/preload/global.d.ts | 5 - apps/desktop/src/preload/preload.test.ts | 63 +- apps/desktop/src/preload/preload.ts | 9 - apps/desktop/src/renderer/browserMock.ts | 9 - .../components/chat/AgentChatComposer.tsx | 52 +- .../chat/AgentChatMessageList.test.tsx | 22 +- .../components/chat/AgentChatMessageList.tsx | 285 ++-- .../components/chat/AgentChatPane.test.tsx | 28 +- .../components/chat/AgentChatPane.tsx | 378 +++-- .../chat/ChatActionsDrawerPanel.tsx | 91 +- .../components/chat/ChatComposerShell.tsx | 5 +- .../components/chat/ChatGitToolbar.tsx | 40 +- .../components/chat/ChatPrInlineCreator.tsx | 238 +++ .../renderer/components/chat/ChatPrPane.tsx | 202 +++ .../components/chat/ChatSubagentsPanel.tsx | 244 ++- .../components/chat/ChatSurfaceShell.tsx | 13 +- .../components/chat/chatAppearance.ts | 2 +- .../renderer/components/chat/chatMarkdown.tsx | 8 +- .../chat/codex/CodexOpenInCliButton.tsx | 169 -- .../components/lanes/LaneWorkPane.tsx | 10 +- .../lanes/useLaneWorkSessions.test.ts | 2 - .../components/lanes/useLaneWorkSessions.ts | 12 +- .../renderer/components/run/QuickRunMenu.tsx | 147 ++ .../terminals/CliSessionWorkSurfaceHeader.tsx | 15 + .../components/terminals/SessionCard.test.tsx | 1 - .../components/terminals/SessionCard.tsx | 65 +- .../terminals/SessionContextMenu.tsx | 17 + .../terminals/SessionListPane.test.tsx | 2 - .../components/terminals/SessionListPane.tsx | 125 +- .../terminals/TerminalsPage.test.tsx | 7 +- .../components/terminals/TerminalsPage.tsx | 183 +- .../components/terminals/WorkGridView.tsx | 165 ++ .../components/terminals/WorkStartSurface.tsx | 4 +- .../terminals/WorkViewArea.test.tsx | 787 +-------- .../components/terminals/WorkViewArea.tsx | 1499 ++--------------- .../terminals/useWorkSessions.test.ts | 57 - .../components/terminals/useWorkSessions.ts | 26 +- .../renderer/components/ui/FloatingPane.tsx | 4 +- .../src/renderer/components/ui/GlowMenu.tsx | 87 +- .../components/ui/PaneTilingLayout.tsx | 66 +- .../components/work/WorkSurfaceHeader.tsx | 133 +- apps/desktop/src/renderer/index.css | 47 +- apps/desktop/src/renderer/lib/workGrid.ts | 120 ++ .../src/renderer/state/appStore.test.ts | 31 +- apps/desktop/src/renderer/state/appStore.ts | 48 +- apps/desktop/src/shared/ipc.ts | 1 - apps/desktop/src/shared/types/chat.ts | 22 - 55 files changed, 2422 insertions(+), 3679 deletions(-) delete mode 100644 apps/desktop/src/main/services/chat/codexCliLauncher.test.ts delete mode 100644 apps/desktop/src/main/services/chat/codexCliLauncher.ts create mode 100644 apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx create mode 100644 apps/desktop/src/renderer/components/chat/ChatPrPane.tsx delete mode 100644 apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/WorkGridView.tsx create mode 100644 apps/desktop/src/renderer/lib/workGrid.ts diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index fc8cd971b..2f0205839 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -312,7 +312,6 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(chatActions).toContain("ensureCtoSession"); expect(chatActions).toContain("ensureAgentIdentitySession"); expect(chatActions).toContain("modelCatalog"); - expect(chatActions).toContain("codexOpenInCli"); expect(ADE_ACTION_ALLOWLIST.cto_state ?? []).toContain("runProjectScan"); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index c87f48cd7..8a0cd2a7d 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -18,8 +18,6 @@ import type { ComputerUseOwnerSnapshotArgs } from "../../../shared/types/compute import type { AgentChatFileSearchArgs, AgentChatFileSearchResult, - AgentChatCodexOpenInCliArgs, - AgentChatCodexOpenInCliResult, AgentChatGetTurnFileDiffArgs, AgentChatLaunchCliArgs, AgentChatLaunchCliResult, @@ -83,12 +81,6 @@ import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; import { mapPermissionModeForModelFamily } from "../prs/resolverUtils"; import { getErrorMessage, isRecord, nowIso } from "../shared/utils"; import { parseLinearGraphQLInput } from "../cto/linearGraphQLInput"; -import { resolveCodexExecutable } from "../ai/codexExecutable"; -import { - buildResumeArgv, - detectCodexResumeStrategy, - spawnInNewTerminalWindow, -} from "../chat/codexCliLauncher"; import { launchAgentChatCli } from "../chat/agentChatCliLaunch"; import { createApnsBridgeService } from "../notifications/apnsBridgeService"; import { deleteTerminalSessionWithRuntimeCleanup } from "../sessions/deleteTerminalSession"; @@ -461,7 +453,6 @@ export const ADE_ACTION_ALLOWLIST: Partial agentChatService.getModelCatalog(args && typeof args === "object" ? args as never : undefined), - codexOpenInCli: async ( - args?: AgentChatCodexOpenInCliArgs, - ): Promise => { - const sessionId = - typeof args?.sessionId === "string" ? args.sessionId.trim() : ""; - if (!sessionId) { - throw new Error("chat.codexOpenInCli requires a sessionId"); - } - const resumeCtx = agentChatService.getCodexResumeContext(sessionId); - if (!resumeCtx) { - throw new Error(`No resumable Codex thread for session ${sessionId}`); - } - if (resumeCtx.provider !== "codex") { - throw new Error("Open-in-CLI is only supported for Codex sessions"); - } - const resolved = resolveCodexExecutable(); - const strategy = await detectCodexResumeStrategy(resolved.path); - const argv = buildResumeArgv(strategy, resumeCtx.threadId); - const result: AgentChatCodexOpenInCliResult = { - binary: resolved.path, - argv, - cwd: resumeCtx.laneWorktreePath, - threadId: resumeCtx.threadId, - copyThreadIdToClipboard: strategy.copyThreadIdToClipboard, - }; - if (args?.mode === "new-window") { - spawnInNewTerminalWindow({ - binary: resolved.path, - argv, - cwd: resumeCtx.laneWorktreePath, - }); - result.spawnedNewWindow = true; - } - return result; - }, setParallelLaunchState: (args?: AgentChatSetParallelLaunchStateArgs) => { const parentLaneId = requireNonEmptyString(args?.parentLaneId, "parentLaneId"); const key = agentChatParallelLaunchStateKey(runtime.projectRoot, parentLaneId); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f0e8ad55f..54c38ddc8 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6268,25 +6268,6 @@ export function createAgentChatService(args: { } }; - const getCodexResumeContext = (sessionId: string): { - sessionId: string; - threadId: string; - laneWorktreePath: string; - provider: AgentChatProvider; - } | null => { - const managed = managedSessions.get(sessionId); - if (!managed) return null; - const { session, laneWorktreePath } = managed; - const threadId = session.threadId?.trim() ?? ""; - if (!threadId.length) return null; - return { - sessionId, - threadId, - laneWorktreePath, - provider: session.provider, - }; - }; - const getChatTranscript = async ({ sessionId, limit = DEFAULT_TRANSCRIPT_READ_LIMIT, @@ -25476,7 +25457,6 @@ export function createAgentChatService(args: { countActiveForLane, disposeForLane, getChatTranscript, - getCodexResumeContext, getChatEventHistory, ensureIdentitySession, approveToolUse, diff --git a/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts b/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts deleted file mode 100644 index 2fffa56f9..000000000 --- a/apps/desktop/src/main/services/chat/codexCliLauncher.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("node:child_process", () => { - const execFileImpl = vi.fn(); - const spawnImpl = vi.fn(); - return { - execFile: execFileImpl, - spawn: spawnImpl, - __execFileMock: execFileImpl, - __spawnMock: spawnImpl, - }; -}); - -import * as cp from "node:child_process"; -import { - buildResumeArgv, - detectCodexResumeStrategy, - shellQuote, - spawnInNewTerminalWindow, -} from "./codexCliLauncher"; - -type MockedCp = typeof cp & { - __execFileMock: ReturnType; - __spawnMock: ReturnType; -}; - -const mocked = cp as MockedCp; - -function stubExecFile(stdout: string, stderr = ""): void { - mocked.__execFileMock.mockImplementation(((...allArgs: unknown[]) => { - const cb = allArgs[allArgs.length - 1]; - if (typeof cb === "function") { - setImmediate(() => (cb as (err: Error | null, stdout: string, stderr: string) => void)(null, stdout, stderr)); - } - return {} as never; - }) as never); -} - -function stubExecFileError(): void { - mocked.__execFileMock.mockImplementation(((...allArgs: unknown[]) => { - const cb = allArgs[allArgs.length - 1]; - if (typeof cb === "function") { - setImmediate(() => (cb as (err: Error) => void)(new Error("ENOENT"))); - } - return {} as never; - }) as never); -} - -describe("codexCliLauncher", () => { - describe("detectCodexResumeStrategy", () => { - it("picks the resume subcommand when --help advertises it", async () => { - stubExecFile("Usage: codex [OPTIONS] [COMMAND]\nCommands:\n resume Resume a thread\n ..."); - const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); - expect(strategy.flagForm.kind).toBe("subcommand"); - expect(strategy.copyThreadIdToClipboard).toBe(false); - expect(buildResumeArgv(strategy, "abc-123")).toEqual(["resume", "abc-123"]); - }); - - it("falls back to --thread when only the flag is in help", async () => { - stubExecFile("Usage: codex [OPTIONS]\n --thread Resume a specific thread\n"); - const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); - expect(strategy.flagForm.kind).toBe("long-flag"); - expect(strategy.copyThreadIdToClipboard).toBe(false); - expect(buildResumeArgv(strategy, "abc-123")).toEqual(["--thread", "abc-123"]); - }); - - it("does not pick the resume subcommand from prose in help text", async () => { - stubExecFile([ - "Usage: codex [OPTIONS]", - "", - "Commands:", - " run Start a new session", - "", - "Use --thread to resume a previous conversation.", - " --thread Resume a specific thread", - ].join("\n")); - const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); - expect(strategy.flagForm.kind).toBe("long-flag"); - expect(buildResumeArgv(strategy, "abc-123")).toEqual(["--thread", "abc-123"]); - }); - - it("falls back to interactive launch + clipboard when neither form exists", async () => { - stubExecFile("Usage: codex [OPTIONS]\nNo resume support"); - const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); - expect(strategy.flagForm.kind).toBe("interactive"); - expect(strategy.copyThreadIdToClipboard).toBe(true); - expect(buildResumeArgv(strategy, "abc-123")).toEqual([]); - }); - - it("falls back to interactive when the --help probe itself fails", async () => { - stubExecFileError(); - const strategy = await detectCodexResumeStrategy("/usr/local/bin/codex"); - expect(strategy.flagForm.kind).toBe("interactive"); - expect(strategy.copyThreadIdToClipboard).toBe(true); - }); - }); - - describe("shellQuote", () => { - it("wraps in single quotes and escapes embedded quotes", () => { - expect(shellQuote("simple")).toBe("'simple'"); - expect(shellQuote("with space")).toBe("'with space'"); - expect(shellQuote("it's \"tricky\"")).toBe("'it'\\''s \"tricky\"'"); - expect(shellQuote("$(touch x)`boom`!")).toBe("'$(touch x)`boom`!'"); - }); - }); - - describe("spawnInNewTerminalWindow", () => { - it("uses osascript on darwin", () => { - const spawnMock = mocked.__spawnMock; - spawnMock.mockReset(); - const fakeChild = { once: vi.fn(), unref: vi.fn() }; - spawnMock.mockReturnValue(fakeChild as never); - - spawnInNewTerminalWindow({ - binary: "/usr/local/$BIN`noop`/codex", - argv: ["resume", "abc-123", "quote\"slash\\tail"], - cwd: "/tmp/$HOME-`touch nope`", - platform: "darwin", - }); - - expect(spawnMock).toHaveBeenCalledTimes(1); - const [bin, args, opts] = spawnMock.mock.calls[0]!; - expect(bin).toBe("osascript"); - expect(args[0]).toBe("-e"); - const script = args[1] as string; - expect(script).toContain("Terminal"); - expect(script).toContain('quoted form of "/tmp/$HOME-`touch nope`"'); - expect(script).toContain('quoted form of "/usr/local/$BIN`noop`/codex"'); - expect(script).toContain('quoted form of "resume"'); - expect(script).toContain('quoted form of "abc-123"'); - expect(script).toContain('quoted form of "quote\\"slash\\\\tail"'); - expect((opts as { detached: boolean }).detached).toBe(true); - expect(fakeChild.unref).toHaveBeenCalled(); - }); - - it("uses cmd /C start cmd /K on win32", () => { - const spawnMock = mocked.__spawnMock; - spawnMock.mockReset(); - const fakeChild = { once: vi.fn(), unref: vi.fn() }; - spawnMock.mockReturnValue(fakeChild as never); - - spawnInNewTerminalWindow({ - binary: "C:\\codex.exe", - argv: ["resume", "abc-123"], - cwd: "C:\\lane", - platform: "win32", - }); - - const [bin, args] = spawnMock.mock.calls[0]!; - expect(bin).toBe("cmd.exe"); - expect(args[0]).toBe("/C"); - expect(args[1]).toBe("start"); - expect(args[2]).toBe("cmd"); - expect(args[3]).toBe("/K"); - expect(args[4]).toContain("resume"); - expect(args[4]).toContain("abc-123"); - }); - - it("falls through to gnome-terminal on linux", () => { - const spawnMock = mocked.__spawnMock; - spawnMock.mockReset(); - const fakeChild = { once: vi.fn(), unref: vi.fn() }; - spawnMock.mockReturnValue(fakeChild as never); - - spawnInNewTerminalWindow({ - binary: "/usr/local/bin/codex", - argv: ["resume", "abc-123"], - cwd: "/tmp/lane", - platform: "linux", - isExecutableOnPath: (binary) => binary === "gnome-terminal", - }); - - const [bin] = spawnMock.mock.calls[0]!; - expect(bin).toBe("gnome-terminal"); - }); - - it("skips unavailable linux terminal candidates", () => { - const spawnMock = mocked.__spawnMock; - spawnMock.mockReset(); - const fakeChild = { once: vi.fn(), unref: vi.fn() }; - spawnMock.mockReturnValue(fakeChild as never); - - spawnInNewTerminalWindow({ - binary: "/usr/local/bin/codex", - argv: ["resume", "abc-123"], - cwd: "/tmp/lane", - platform: "linux", - isExecutableOnPath: (binary) => binary === "xterm", - }); - - const [bin] = spawnMock.mock.calls[0]!; - expect(bin).toBe("xterm"); - }); - - it("passes xfce4-terminal an argv-style command", () => { - const spawnMock = mocked.__spawnMock; - spawnMock.mockReset(); - const fakeChild = { once: vi.fn(), unref: vi.fn() }; - spawnMock.mockReturnValue(fakeChild as never); - - spawnInNewTerminalWindow({ - binary: "/usr/local/bin/codex", - argv: ["resume", "abc-123"], - cwd: "/tmp/lane with spaces", - platform: "linux", - isExecutableOnPath: (binary) => binary === "xfce4-terminal", - }); - - const [bin, args] = spawnMock.mock.calls[0]!; - expect(bin).toBe("xfce4-terminal"); - expect(args).toEqual([ - "--execute", - "bash", - "-lc", - "cd '/tmp/lane with spaces' && '/usr/local/bin/codex' 'resume' 'abc-123'; exec bash", - ]); - }); - - it("throws when no linux terminal candidate is available", () => { - expect(() => spawnInNewTerminalWindow({ - binary: "/usr/local/bin/codex", - argv: ["resume", "abc-123"], - cwd: "/tmp/lane", - platform: "linux", - isExecutableOnPath: () => false, - })).toThrow(/no supported terminal emulator/); - }); - }); -}); diff --git a/apps/desktop/src/main/services/chat/codexCliLauncher.ts b/apps/desktop/src/main/services/chat/codexCliLauncher.ts deleted file mode 100644 index a7610585c..000000000 --- a/apps/desktop/src/main/services/chat/codexCliLauncher.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { execFile, spawn } from "node:child_process"; -import { accessSync, constants } from "node:fs"; -import path from "node:path"; - -function execFileAsync( - binary: string, - args: string[], - options: { timeout?: number }, -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - execFile(binary, args, options, (err, stdout, stderr) => { - if (err) { - reject(err); - return; - } - resolve({ stdout: stdout.toString(), stderr: stderr.toString() }); - }); - }); -} - -export type CodexResumeFlagForm = - | { kind: "subcommand"; argv: (threadId: string) => string[] } - | { kind: "long-flag"; argv: (threadId: string) => string[] } - | { kind: "interactive"; argv: () => string[] }; - -export type CodexResumeStrategy = { - /** Path to the codex binary (typically bundled). */ - binary: string; - /** How to launch a resume. `interactive` means launch codex without args and rely on the user's Ctrl+R picker. */ - flagForm: CodexResumeFlagForm; - /** True when we could not detect a `resume` subcommand or `--thread` flag; the caller should copy the threadId to clipboard. */ - copyThreadIdToClipboard: boolean; -}; - -/** - * Probe `codex --help` and decide which flag form to use to resume a specific thread. - * Returns a strategy that tells the caller how to spawn codex (and whether to also - * copy the threadId to the clipboard as a fallback when no direct flag exists). - */ -export async function detectCodexResumeStrategy(binary: string): Promise { - let helpText = ""; - try { - const { stdout, stderr } = await execFileAsync(binary, ["--help"], { timeout: 5000 }); - helpText = `${stdout}\n${stderr}`; - } catch (probeError) { - // If we can't read --help, fall back to interactive launch with clipboard. - return { - binary, - flagForm: { kind: "interactive", argv: () => [] }, - copyThreadIdToClipboard: true, - }; - } - - const lower = helpText.toLowerCase(); - // Prefer the explicit `resume` subcommand (post-0.130 form). Match command - // table entries only (e.g. " resume Resume a thread"), not prose. - if (/^\s{2,}resume(?:\s{2,}|\t|$)/m.test(lower)) { - return { - binary, - flagForm: { kind: "subcommand", argv: (id) => ["resume", id] }, - copyThreadIdToClipboard: false, - }; - } - if (/--thread\b/.test(lower)) { - return { - binary, - flagForm: { kind: "long-flag", argv: (id) => ["--thread", id] }, - copyThreadIdToClipboard: false, - }; - } - return { - binary, - flagForm: { kind: "interactive", argv: () => [] }, - copyThreadIdToClipboard: true, - }; -} - -export function buildResumeArgv(strategy: CodexResumeStrategy, threadId: string): string[] { - if (strategy.flagForm.kind === "interactive") return strategy.flagForm.argv(); - return strategy.flagForm.argv(threadId); -} - -/** Quote a single arg for an interactive shell command. */ -export function shellQuote(arg: string): string { - return `'${arg.replace(/'/g, "'\\''")}'`; -} - -function cmdQuote(arg: string): string { - return `"${arg - .replace(/(["^&|<>])/g, "^$1") - .replace(/%/g, "%%")}"`; -} - -function appleScriptStringLiteral(value: string): string { - return `"${value - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\r/g, "\\r") - .replace(/\n/g, "\\n")}"`; -} - -function terminalShellCommandExpression(binary: string, argv: string[], cwd: string): string { - const parts = [ - `"cd "`, - `quoted form of ${appleScriptStringLiteral(cwd)}`, - `" && "`, - `quoted form of ${appleScriptStringLiteral(binary)}`, - ]; - for (const arg of argv) { - parts.push(`" "`, `quoted form of ${appleScriptStringLiteral(arg)}`); - } - return parts.join(" & "); -} - -export type SpawnNewTerminalOptions = { - binary: string; - argv: string[]; - cwd: string; - platform?: NodeJS.Platform; - isExecutableOnPath?: (binary: string) => boolean; -}; - -function isExecutableOnPath(binary: string): boolean { - if (binary.includes("/") || binary.includes("\\")) { - try { - accessSync(binary, constants.X_OK); - return true; - } catch { - return false; - } - } - - for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { - if (!dir) continue; - try { - accessSync(path.join(dir, binary), constants.X_OK); - return true; - } catch { - // keep scanning PATH - } - } - return false; -} - -function spawnDetached(binary: string, args: string[], options: Parameters[2]): void { - const child = spawn(binary, args, options); - child.once("error", () => undefined); - child.unref(); -} - -/** - * Launch the user's default terminal with `codex ` running inside, cd'd - * to `cwd`. Returns once the launcher process has been spawned (detached). - */ -export function spawnInNewTerminalWindow(options: SpawnNewTerminalOptions): void { - const platform = options.platform ?? process.platform; - const quote = platform === "win32" ? cmdQuote : shellQuote; - const command = [options.binary, ...options.argv].map(quote).join(" "); - const cdCommand = platform === "win32" ? `cd /d ${quote(options.cwd)}` : `cd ${quote(options.cwd)}`; - const executableAvailable = options.isExecutableOnPath ?? isExecutableOnPath; - - if (platform === "darwin") { - // Use osascript so we can set cwd cleanly and `do script` runs an interactive shell. - const script = `tell application "Terminal" to do script ${terminalShellCommandExpression(options.binary, options.argv, options.cwd)}`; - spawnDetached("osascript", ["-e", script], { detached: true, stdio: "ignore" }); - return; - } - - if (platform === "win32") { - // `start cmd /K " && "` opens a new console window that stays open after the command exits. - const inner = `${cdCommand} && ${command}`; - spawnDetached("cmd.exe", ["/C", "start", "cmd", "/K", inner], { detached: true, stdio: "ignore", windowsHide: false }); - return; - } - - // Linux/BSD: try a list of terminals; use the first one that's on PATH. - const candidates: Array<{ bin: string; argv: (script: string) => string[] }> = [ - { bin: "gnome-terminal", argv: (s) => ["--", "bash", "-c", s] }, - { bin: "konsole", argv: (s) => ["-e", "bash", "-c", s] }, - { bin: "xfce4-terminal", argv: (s) => ["--execute", "bash", "-lc", s] }, - { bin: "xterm", argv: (s) => ["-e", "bash", "-c", s] }, - ]; - const innerScript = `${cdCommand} && ${command}; exec bash`; - for (const candidate of candidates) { - if (!executableAvailable(candidate.bin)) continue; - spawnDetached(candidate.bin, candidate.argv(innerScript), { detached: true, stdio: "ignore" }); - return; - } - // Last resort: xdg-terminal (often a shim on modern desktops). - if (executableAvailable("xdg-terminal")) { - spawnDetached("xdg-terminal", [`${cdCommand} && ${command}`], { detached: true, stdio: "ignore" }); - return; - } - throw new Error("Failed to spawn terminal: no supported terminal emulator found"); -} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index abd040784..bd9a7e81f 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -276,8 +276,6 @@ import type { AgentChatArchiveArgs, AgentChatCodexClearGoalArgs, AgentChatCodexGetGoalArgs, - AgentChatCodexOpenInCliArgs, - AgentChatCodexOpenInCliResult, AgentChatCodexSetGoalArgs, AgentChatCodexSetGoalStatusArgs, CodexThreadGoal, @@ -658,12 +656,6 @@ import type { createProjectScaffoldService } from "../projects/projectScaffoldSe import type { createAdeCliService } from "../cli/adeCliService"; import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot } from "../shared/utils"; import { quoteWindowsCmdArg } from "../shared/processExecution"; -import { resolveCodexExecutable } from "../ai/codexExecutable"; -import { - buildResumeArgv, - detectCodexResumeStrategy, - spawnInNewTerminalWindow, -} from "../chat/codexCliLauncher"; import { sanitizeResumeTargetId } from "../../utils/terminalSessionSignals"; import { probeLocalhostPort } from "../probeLocalhostPort"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; @@ -6775,52 +6767,6 @@ export function registerIpc({ return { ok: true }; }); - ipcMain.handle(IPC.agentChatCodexOpenInCli, async ( - event, - arg: AgentChatCodexOpenInCliArgs, - ): Promise => { - assertTrustedAppControlSender(event, IPC.agentChatCodexOpenInCli); - if (arg?.mode === "new-window") { - assertAppControlRateLimit(event, IPC.agentChatCodexOpenInCli, { windowMs: 10_000, max: 10 }); - } - - const ctx = getCtx(); - const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; - const mode = arg?.mode === "new-window" ? "new-window" : "ade-terminal"; - if (!sessionId) { - throw new Error("agentChat.codex.openInCli requires a sessionId"); - } - if (!ctx.agentChatService) { - throw new Error("Open in Codex CLI is unavailable until a project is loaded in this window."); - } - const resumeCtx = ctx.agentChatService.getCodexResumeContext(sessionId); - if (!resumeCtx) { - throw new Error(`No resumable Codex thread for session ${sessionId}`); - } - if (resumeCtx.provider !== "codex") { - throw new Error("Open-in-CLI is only supported for Codex sessions"); - } - const resolved = resolveCodexExecutable(); - const strategy = await detectCodexResumeStrategy(resolved.path); - const argv = buildResumeArgv(strategy, resumeCtx.threadId); - const result: AgentChatCodexOpenInCliResult = { - binary: resolved.path, - argv, - cwd: resumeCtx.laneWorktreePath, - threadId: resumeCtx.threadId, - copyThreadIdToClipboard: strategy.copyThreadIdToClipboard, - }; - if (mode === "new-window") { - spawnInNewTerminalWindow({ - binary: resolved.path, - argv, - cwd: resumeCtx.laneWorktreePath, - }); - result.spawnedNewWindow = true; - } - return result; - }); - ipcMain.handle(IPC.computerUseListArtifacts, async (_event, arg: ComputerUseArtifactListArgs = {}): Promise => { const ctx = ensureComputerUseBroker(); return ctx.computerUseArtifactBrokerService.listArtifacts(arg); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 2f67efc01..1d8b0ecba 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -403,7 +403,7 @@ describe("registerRuntimeBridge", () => { it("forwards local action registry listing through the authorized local runtime root", async () => { const registry = [ - { domain: "chat", actions: [{ name: "codexOpenInCli" }] }, + { domain: "chat", actions: [{ name: "launchCli" }] }, { domain: "git", actions: [{ name: "status" }] }, ]; const localRuntimeConnectionPool = { @@ -534,7 +534,7 @@ describe("registerRuntimeBridge", () => { it("forwards remote project action registry listing through the selected target and project", async () => { const registry = [ - { domain: "chat", actions: [{ name: "codexOpenInCli" }] }, + { domain: "chat", actions: [{ name: "launchCli" }] }, { domain: "git", actions: [{ name: "status" }] }, ]; remoteRegistryGetMock.mockReturnValue(target); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts index cd93f9d8f..4ebd4a279 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteConnectionPool.test.ts @@ -563,9 +563,9 @@ describe("RemoteConnectionPool", () => { }, { domain: "chat", - action: "codexOpenInCli", - name: "chat.codexOpenInCli", - usage: "ade actions run chat.codexOpenInCli", + action: "launchCli", + name: "chat.launchCli", + usage: "ade actions run chat.launchCli", }, { domain: "git", @@ -587,7 +587,7 @@ describe("RemoteConnectionPool", () => { ).resolves.toEqual([ { domain: "chat", - actions: [{ name: "codexOpenInCli" }], + actions: [{ name: "launchCli" }], }, { domain: "git", diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 48d45a829..991da878d 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -80,8 +80,6 @@ import type { AgentChatArchiveArgs, AgentChatCodexClearGoalArgs, AgentChatCodexGetGoalArgs, - AgentChatCodexOpenInCliArgs, - AgentChatCodexOpenInCliResult, AgentChatCodexSetGoalArgs, AgentChatCodexSetGoalStatusArgs, AgentChatCreateArgs, @@ -1417,9 +1415,6 @@ declare global { clearGoal: ( args: AgentChatCodexClearGoalArgs, ) => Promise; - openInCli: ( - args: AgentChatCodexOpenInCliArgs, - ) => Promise; }; }; computerUse: { diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index eed558040..037da3d6d 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -1384,7 +1384,7 @@ describe("preload OAuth bridge", () => { displayName: "Project", }; const registry = [ - { domain: "chat", actions: [{ name: "codexOpenInCli" }] }, + { domain: "chat", actions: [{ name: "launchCli" }] }, { domain: "git", actions: [{ name: "status" }] }, ]; const invoke = vi.fn(async (channel: string, payload?: unknown) => { @@ -3065,67 +3065,6 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatSlashCommands, input); }); - it("routes Codex open-in-CLI setup through a remote project runtime when bound", async () => { - const binding = { - kind: "remote", - key: "remote:target-1:project-1", - targetId: "target-1", - runtimeName: "Remote", - projectId: "project-1", - rootPath: "/remote/project", - displayName: "Project", - }; - const input = { sessionId: "session-1", mode: "ade-terminal" }; - const result = { - binary: "/usr/local/bin/codex", - argv: ["resume", "thread-1"], - cwd: "/remote/project/.ade/worktrees/lane", - threadId: "thread-1", - copyThreadIdToClipboard: false, - }; - const invoke = vi.fn(async (channel: string) => { - if (channel === IPC.appGetWindowSession) { - return { windowId: 1, project: null, binding }; - } - if (channel === IPC.remoteRuntimeCallAction) { - return { ok: true, domain: "chat", action: "codexOpenInCli", result, statusHints: {} }; - } - return undefined; - }); - const on = vi.fn(); - const removeListener = vi.fn(); - const exposeInMainWorld = vi.fn((name: string, value: unknown) => { - (globalThis as any).__bridgeName = name; - (globalThis as any).__adeBridge = value; - }); - - vi.doMock("electron", () => ({ - contextBridge: { exposeInMainWorld }, - ipcRenderer: { invoke, on, removeListener }, - webFrame: { - getZoomLevel: vi.fn(() => 0), - setZoomLevel: vi.fn(), - getZoomFactor: vi.fn(() => 1), - }, - })); - - await import("./preload"); - - const bridge = (globalThis as any).__adeBridge; - await expect(bridge.agentChat.codex.openInCli(input)).resolves.toEqual(result); - - expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { - id: "target-1", - projectId: "project-1", - request: { - domain: "chat", - action: "codexOpenInCli", - args: input, - }, - }); - expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatCodexOpenInCli, input); - }); - it("routes CLI agent launches through a remote project runtime when bound", async () => { const binding = { kind: "remote", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 765faf1df..a1d372e69 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -298,8 +298,6 @@ import type { AgentChatLaunchArgs, AgentChatLaunchCliArgs, AgentChatLaunchCliResult, - AgentChatCodexOpenInCliArgs, - AgentChatCodexOpenInCliResult, AgentChatCodexSetGoalArgs, AgentChatCodexSetGoalStatusArgs, AgentChatDeleteArgs, @@ -1202,7 +1200,6 @@ const MUTATING_CHAT_ACTIONS = new Set([ "setCodexGoal", "setCodexGoalStatus", "clearCodexGoal", - "codexOpenInCli", ]); const READ_ONLY_RUNTIME_ACTION_PREFIXES = [ @@ -5252,12 +5249,6 @@ contextBridge.exposeInMainWorld("ade", { agentChatSummaryCache.clear(); return goal as CodexThreadGoal | null; }, - openInCli: ( - args: AgentChatCodexOpenInCliArgs, - ): Promise => - callProjectRuntimeActionOr("chat", "codexOpenInCli", { args }, () => - ipcRenderer.invoke(IPC.agentChatCodexOpenInCli, args), - ), }, readTranscript: (args: { sessionId: string; diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 4a425b23b..17abbdd53 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4680,15 +4680,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { supportsInterrupt: false, }), saveTempAttachment: resolvedArg({ path: "/tmp/browser-mock-attachment" }), - codex: { - openInCli: async (_args: any) => ({ - binary: "/usr/local/bin/codex", - argv: [] as string[], - cwd: "/tmp/browser-mock-lane", - threadId: "browser-mock-thread", - copyThreadIdToClipboard: true, - }), - }, getEventHistory: async (arg: { sessionId: string; maxEvents?: number; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index e90e5ca92..fb22a6623 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1104,7 +1104,6 @@ export function AgentChatComposer({ && launchPromptClipboardNoticeEnabled && !composerInputLocked && draft.trim().length > 0; - const showLaunchClipboardHelper = showLaunchClipboardNotice && composerFocused; const resizeTextarea = useCallback(() => { if (useRichComposer) return; @@ -2456,16 +2455,11 @@ export function AgentChatComposer({ parallelModelSlots, ]); + // Clean composer: no provider-tinted glow border (that produced the bright + // "highlighted" outline). Only the orchestrator's special mode keeps a glow. const composerGlowColor = useMemo(() => { - if (orchestratorModeActive) return "rgba(217, 70, 239, 0.36)"; - const provider = sessionProvider ?? (modelId ? "anthropic" : null); - if (!provider) return null; - if (provider === "anthropic") return "rgba(249, 115, 22, 0.25)"; - if (provider === "openai") return "rgba(255, 255, 255, 0.15)"; - if (provider === "cursor") return "rgba(59, 130, 246, 0.25)"; - if (provider === "opencode") return "rgba(255, 255, 255, 0.12)"; - return null; - }, [orchestratorModeActive, sessionProvider, modelId]); + return orchestratorModeActive ? "rgba(217, 70, 239, 0.36)" : null; + }, [orchestratorModeActive]); /* ── Keyboard handler for composer input ── */ const handleKeyDown = (event: React.KeyboardEvent) => { @@ -2774,7 +2768,10 @@ export function AgentChatComposer({ // Idle composer motion keeps the GPU busy; keep the animated beam to active // turns and explicit orchestration mode. - const composerBeamActive = isActive + // BorderBeam disabled — the traveling beam around the composer read as + // distracting chrome. (Orchestrator mode keeps its own separate glow.) + const composerBeamActive = false + && isActive && layoutVariant !== "grid-tile" && !iosSimulatorOpen && (turnActive || orchestratorModeActive); @@ -2915,6 +2912,18 @@ export function AgentChatComposer({ }} onOpenLinearSettings={onOpenLinearSettings} /> + {showLaunchClipboardNotice && layoutVariant !== "grid-tile" ? ( +
+ Prompt copies to clipboard on send.{" "} + +
+ ) : null} - {showLaunchClipboardNotice ? ( -
- After submission your prompt will auto copy to clipboard. -
- ) : null} {parallelChatMode ? (
@@ -4109,18 +4115,6 @@ export function AgentChatComposer({ onPaste={handlePaste} /> )} - {showLaunchClipboardHelper ? ( -
- Prompt will be copied to clipboard after Send.{" "} - -
- ) : null}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 05c42e977..5a34fe3bc 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -556,7 +556,7 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("Usage")).toBeTruthy(); + // The calm turn footer dropped the full-width "Usage" label — model attribution is the signal. expect(screen.getAllByText(/Claude Sonnet 4\.6/).length).toBeGreaterThan(0); }); @@ -1459,13 +1459,16 @@ describe("AgentChatMessageList transcript rendering", () => { const streaming = renderMessageList(sharedEvents, { showStreamingIndicator: true }); - expect(streaming.container.textContent).toContain("Running command: npm test"); + // The single working indicator surfaces the concise activity label, never + // the raw tool detail (kept calm — t3code / Codex reference). + expect(streaming.container.textContent).toContain("Running command"); + expect(streaming.container.textContent).not.toContain("npm test"); cleanup(); const transcriptOnly = renderMessageList(sharedEvents, { showStreamingIndicator: false }); - expect(transcriptOnly.container.textContent).not.toContain("Running command: npm test"); + expect(transcriptOnly.container.textContent).not.toContain("Running command"); }); it("keeps thinking activity visible after a duplicate started status", () => { @@ -1503,11 +1506,12 @@ describe("AgentChatMessageList transcript rendering", () => { { showStreamingIndicator: true }, ); - expect(rendered.container.textContent).toContain("Thinking: Thinking through the answer"); - expect(rendered.container.innerHTML).toContain("ade-shimmer-text"); + // Single calm working indicator: concise "Thinking" label, no raw detail / shimmer text. + expect(rendered.container.textContent).toContain("Thinking"); + expect(rendered.container.textContent).not.toContain("Thinking through the answer"); }); - it("keeps the live assistant bubble stable until the turn finishes", () => { + it("keeps the live assistant message stable until the turn finishes", () => { const live = renderMessageList( [ { @@ -1524,7 +1528,9 @@ describe("AgentChatMessageList transcript rendering", () => { { showStreamingIndicator: true }, ); - expect(live.container.innerHTML).toContain("ade-glow-pulse"); + // Assistant prose is unbubbled and calm now — no glow-pulse; the live text + // simply renders and stays stable through the turn. + expect(live.container.textContent).toContain("Streaming response"); cleanup(); @@ -1554,7 +1560,7 @@ describe("AgentChatMessageList transcript rendering", () => { { showStreamingIndicator: false }, ); - expect(settled.container.innerHTML).not.toContain("ade-glow-pulse"); + expect(settled.container.textContent).toContain("Streaming response"); }); it("shows streamed live reasoning text instead of only a thinking placeholder", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 5eefb7d85..f2a4833be 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -56,7 +56,6 @@ import { isPathEqualOrDescendant, isWindowsAbsolutePath, normalizePath } from ". import { describeToolIdentifier, replaceInternalToolNames } from "./toolPresentation"; import { chatChipToneClass } from "./chatSurfaceTheme"; import { - CHAT_ASSISTANT_MESSAGE_CARD_STYLE, CHAT_TRANSCRIPT_GLASS_CARD_CLASS, CHAT_USER_MESSAGE_CARD_STYLE, CHAT_WORK_LOG_CARD_CLASS, @@ -198,9 +197,10 @@ function approvalToneClass(state: PendingInputResolution | null): string { } function doneStatusToneClass(status: Extract["status"]): string { - if (status === "completed") return "border-white/[0.04] bg-[#141220]/60 text-fg/45"; - if (status === "failed") return "border-red-500/12 bg-red-500/[0.04] text-red-300"; - return "border-amber-500/12 bg-amber-500/[0.04] text-amber-300"; + // Text-only tones — no band/box. Interrupted/failed read as a calm tinted line. + if (status === "completed") return "text-fg/45"; + if (status === "failed") return "text-red-300/80"; + return "text-amber-300/85"; } function completionReportToneClass(status: AgentChatCompletionStatus): string { @@ -347,8 +347,6 @@ const SURFACE_INLINE_CARD_STYLE: React.CSSProperties = { borderColor: "color-mix(in srgb, var(--chat-glass-border) 100%, transparent)", }; -const ASSISTANT_MESSAGE_CARD_STYLE = CHAT_ASSISTANT_MESSAGE_CARD_STYLE; - function describeUserDeliveryState(event: Extract): { label: string; className: string } | null { if (event.deliveryState === "failed") { return { @@ -1137,6 +1135,36 @@ function ThinkingDots({ toneClass = "bg-emerald-300/70" }: { toneClass?: string ); } +/** + * The single, calm "model is working" indicator (replaces the prior tangle of + * shimmer-text / emerald + violet dot variants). Three violet pulses + a + * concise verb + a self-ticking elapsed timer that mutates textContent via a + * ref — no per-second React commit (t3code / Codex desktop reference). + */ +function WorkingIndicator({ activity }: { activity: string | null }) { + const timerRef = useRef(null); + useEffect(() => { + const start = performance.now(); + const el = timerRef.current; + if (!el) return; + let handle = 0; + const tick = () => { + el.textContent = `${Math.floor((performance.now() - start) / 1000)}s`; + handle = window.setTimeout(tick, 1000); + }; + tick(); + return () => window.clearTimeout(handle); + }, []); + return ( + + + {activity ?? "Working"} + · + 0s + + ); +} + /** Three dots: animated while reasoning streams; larger static dots when the turn is done (stay visible on dark chat bg). */ function ReasoningStateDots({ animated }: { animated: boolean }) { return ( @@ -1937,6 +1965,11 @@ function InlineQuestionRequestCard({ ); } +// Tracks which user messages have already played their send-up entrance, so the +// optimistic→delivered swap (and virtualized re-mounts) don't replay it — that +// replay read as a flicker once the bubble settled. +const animatedUserMessageKeys = new Set(); + function renderEvent( envelope: RenderEnvelope, options?: { @@ -1959,7 +1992,6 @@ function renderEvent( } ) { const event = envelope.event; - const activeTurnMotionEnabled = Boolean(options?.turnActive); /* ── User message ── */ if (event.type === "user_message") { @@ -1970,12 +2002,15 @@ function renderEvent( if (event.deliveryState === "queued" && event.steerId) { return null; } + const playSendEntrance = !animatedUserMessageKeys.has(envelope.key); + if (playSendEntrance) animatedUserMessageKeys.add(envelope.key); return (
+
{displayText}
@@ -2027,13 +2062,13 @@ function renderEvent( const parsed = parseLeadingIosContextChips(event.text); if (!parsed.chips.length) { return ( -
+
{event.text}
); } return ( -
+
{parsed.chips.map((label, idx) => ( -
-
- {activeTurnMotionEnabled && ( -
-
-
- )} -
+ {/* Unbubbled assistant prose — plain markdown on the flat canvas (Codex/t3 reference). */} +
+
@@ -3062,61 +3085,9 @@ function renderEvent( /* ── Done ── */ if (event.type === "done") { - const { label: modelLabel } = resolveModelMeta(event.modelId, event.model); - const inputTokens = formatTokenCount(event.usage?.inputTokens); - const outputTokens = formatTokenCount(event.usage?.outputTokens); - const cacheRead = formatTokenCount(event.usage?.cacheReadTokens); - const cacheCreation = formatTokenCount(event.usage?.cacheCreationTokens); - const costLabel = typeof event.costUsd === "number" && event.costUsd > 0 - ? `$${event.costUsd < 0.01 ? event.costUsd.toFixed(4) : event.costUsd.toFixed(2)}` - : null; - const isCloud = event.runtime === "cloud"; - const hasUsageData = Boolean(inputTokens || outputTokens || cacheRead || cacheCreation || costLabel || modelLabel); - if (event.status === "completed" && !hasUsageData && !isCloud) { - return null; - } - const statusTone = doneStatusToneClass(event.status); - - return ( -
-
- Usage - {modelLabel ? ( - - - {modelLabel} - - ) : null} - {isCloud ? ( - - - cloud - - ) : null} - {inputTokens ? In {inputTokens} : null} - {outputTokens ? Out {outputTokens} : null} - {cacheRead ? Cache {cacheRead} : null} - {cacheCreation ? New cache {cacheCreation} : null} - {costLabel ? {costLabel} : null} - {event.status !== "completed" ? ( - {event.status} - ) : null} - {(event.subagentStoppedCount ?? 0) > 0 ? ( - - {event.subagentStoppedCount} subagent{event.subagentStoppedCount === 1 ? "" : "s"} stopped - - ) : null} -
-
- ); + // Rendered as the end-of-turn divider by EventRow (see DoneTurnDivider), + // which needs the per-turn worked-for duration. Nothing inline here. + return null; } /* ── Turn diff summary (minimal inline indicator — detail lives in bottom Tasks panel) ── */ @@ -3317,30 +3288,54 @@ function formatTurnDuration(durationMs: number): string { return remSeconds ? `${minutes}m ${remSeconds}s` : `${minutes}m`; } -function TurnDivider({ summary }: { summary: TurnSummary }) { - if (!summary.ended) return null; - const taskLine = summary.taskCount ? `${summary.completedTaskCount}/${summary.taskCount} tasks complete` : null; - const agentLine = summary.backgroundAgentCount - ? `${summary.backgroundAgentCount} background ${summary.backgroundAgentCount === 1 ? "agent" : "agents"}${ - summary.activeBackgroundAgentCount > 0 ? ` · ${summary.activeBackgroundAgentCount} still running` : " finished" - }` +/** + * End-of-turn divider — a hairline with a plain-text cutout. Shows the wall time + * (or the interrupted/failed status + model) plus how long the turn worked. It + * is driven by the universal `done` event, so it renders identically for every + * runtime (Codex / Claude / Cursor / Droid / OpenCode). + */ +function DoneTurnDivider({ + event, + timestamp, + durationMs, +}: { + event: Extract; + timestamp: string; + durationMs: number | null; +}) { + const completed = event.status === "completed"; + const { label: modelLabel } = resolveModelMeta(event.modelId, event.model); + const workedFor = durationMs !== null && durationMs > 1500 + ? `Worked for ${formatTurnDuration(durationMs)}` : null; - const label = summary.durationMs !== null - ? `Response · Worked for ${formatTurnDuration(summary.durationMs)}` - : "Response"; return ( -
-
- - {label} - -
- {(taskLine || agentLine) ? ( -
- {taskLine ? · {taskLine} : null} - {agentLine ? · {agentLine} : null} -
- ) : null} +
+ + + {!completed && modelLabel ? ( + + + {modelLabel} + + ) : null} + {completed ? ( + {formatTime(timestamp)} + ) : ( + {event.status} + )} + {workedFor ? ( + <> + · + {workedFor} + + ) : null} + +
); } @@ -3387,6 +3382,7 @@ type EventRowProps = { showTurnDivider: boolean; turnDividerLabel: string | null; turnModel: { label: string; modelId?: string; model?: string } | null; + turnEndDurationMs?: number | null; onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; @@ -3413,6 +3409,7 @@ const EventRow = React.memo(function EventRow({ showTurnDivider, turnDividerLabel, turnModel, + turnEndDurationMs, onApproval, surfaceMode = "standard", surfaceProfile = "standard", @@ -3439,15 +3436,15 @@ const EventRow = React.memo(function EventRow({ return (
{showTurnDivider ? ( -
- +
+ - {turnDividerLabel ?? "Turn"} + {turnDividerLabel ?? "Turn"} - +
) : null} {envelope.event.type === "work_log_group" @@ -3483,6 +3480,13 @@ const EventRow = React.memo(function EventRow({ onRevealChatTerminal, onRewindFiles, })} + {envelope.event.type === "done" ? ( + + ) : null}
); }); @@ -4234,6 +4238,24 @@ function AgentChatMessageListMain({ return nextState; }, [events]); const turnSummary = useMemo(() => deriveTurnSummary(events, turnModelState), [events, turnModelState]); + // Per-turn worked-for duration, keyed by grouped-row index, derived from the + // universal `done` event (runtime-agnostic — no reliance on turnId). + const turnEndDurationByRowIndex = useMemo(() => { + const map = new Map(); + let turnStartMs: number | null = null; + for (let i = 0; i < groupedRows.length; i += 1) { + const env = groupedRows[i]; + if (!env) continue; + const ts = Date.parse(env.timestamp); + if (turnStartMs === null && Number.isFinite(ts)) turnStartMs = ts; + if (env.event.type === "done") { + const start = turnStartMs ?? ts; + map.set(i, Number.isFinite(ts) && Number.isFinite(start) ? Math.max(0, ts - start) : 0); + turnStartMs = null; + } + } + return map; + }, [groupedRows]); const handleReviewChanges = useCallback(() => { if (!turnSummary?.changedFileCount) return; @@ -4559,11 +4581,13 @@ function AgentChatMessageListMain({ /** Renders a single row with turn-divider logic. Used by both paths. */ const renderRow = useCallback((envelope: TranscriptGroupedEnvelope, index: number, virtualized: boolean) => { const currentTurn = getGroupedTurnId(envelope); - const previousTurn = getGroupedTurnId(groupedRows[index - 1]); - const showTurnDivider = currentTurn && currentTurn !== previousTurn; - const turnDividerLabel = showTurnDivider - ? formatTime(envelope.timestamp) - : null; + // Turn dividers render at the END of a turn (the `done` row) for every + // runtime; the old start-of-turn boundary divider is disabled. + const showTurnDivider = false; + const turnDividerLabel: string | null = null; + const turnEndDurationMs = envelope.event.type === "done" + ? (turnEndDurationByRowIndex.get(index) ?? null) + : undefined; const turnModel = currentTurn ? (turnModelState.map.get(currentTurn) ?? null) : turnModelState.lastModel; @@ -4581,6 +4605,7 @@ function AgentChatMessageListMain({ showTurnDivider={Boolean(showTurnDivider)} turnDividerLabel={turnDividerLabel} turnModel={turnModel} + turnEndDurationMs={turnEndDurationMs} onApproval={handleApproval} surfaceMode={surfaceMode} surfaceProfile={surfaceProfile} @@ -4648,8 +4673,6 @@ function AgentChatMessageListMain({ return Math.max(0, h); }, [shouldVirtualize, endIndex, groupedRows.length, rowHeight, timelineRowGapPx]); - const streamingIndicatorAnimated = showStreamingIndicator - && !sessionEnded; const streamingIndicator = showStreamingIndicator && !sessionEnded ? ( - {latestActivity ? ( - - {formatActivityText(latestActivity.activity, latestActivity.detail)} - - ) : !streamingIndicatorAnimated ? ( - - Working... - - ) : ( - - - Working… - - )} + ) : null; - const turnDivider = turnSummary ? : null; + // End-of-turn dividers now render inline at each `done` row (DoneTurnDivider), + // so there is no separate bottom divider. + const turnDivider = null; // Jump-to-latest pill is only meaningful during an active turn — if nothing // is streaming there's no "latest" to catch up to. @@ -4702,7 +4709,7 @@ function AgentChatMessageListMain({ onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} > -
+
{rows.length === 0 && !streamingIndicator ? ( null ) : shouldVirtualize ? ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 030519dda..9598a0c82 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1007,13 +1007,34 @@ describe("AgentChatPane companion drawers", () => { }); }); - it("opens the proof drawer and persists split resize from the real divider", async () => { + it("opens the proof drawer as a floating info pane (no split divider)", async () => { renderDrawerPane(); 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(); + // Chat actions is an info pane: it floats over the right gutter created by + // the centered transcript, so it does NOT get a resizable split divider. + expect(screen.queryByRole("separator")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Close chat actions drawer" })); + await waitFor(() => { + expect(screen.queryByText("No artifacts captured yet.")).toBeNull(); + }); + }); + + it("persists split resize from the real divider on a working panel", async () => { + renderDrawerPane(); + + // App Control is a heavy working panel that keeps the resizable split (and + // therefore the drag divider) — unlike the floating chat-actions info pane. + await waitFor(() => { + expect(screen.getAllByRole("button", { name: "Open App Control drawer" }).length).toBeGreaterThan(0); + }); + fireEvent.click(screen.getAllByRole("button", { name: "Open App Control drawer" })[0]!); + expect(screen.getByTestId("app-control-panel").textContent).toBe("App Control panel mounted"); + const divider = screen.getByRole("separator", { name: "" }); const splitParent = divider.parentElement; expect(splitParent).toBeInstanceOf(HTMLElement); @@ -1039,11 +1060,6 @@ describe("AgentChatPane companion drawers", () => { await waitFor(() => { expect(window.sessionStorage.getItem("ade.chat.rightPaneSplit")).toBe("40"); }); - - fireEvent.click(screen.getByRole("button", { name: "Close chat actions drawer" })); - await waitFor(() => { - expect(screen.queryByText("No artifacts captured yet.")).toBeNull(); - }); }); it("restores an archived chat from the archived selector", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 953e5b316..6fffff7c4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -104,12 +104,11 @@ import { ChatAppControlPanel } from "./ChatAppControlPanel"; import { ChatSubagentsPanel } from "./ChatSubagentsPanel"; import { ChatTasksPanel } from "./ChatTasksPanel"; import { ChatFileChangesPanel } from "./ChatFileChangesPanel"; -import { CodexOpenInCliButton } from "./codex/CodexOpenInCliButton"; import { RewindFilesConfirmDialog, type RewindFilesConfirmDialogState } from "./RewindFilesConfirmDialog"; import { buildRewindPreviewFiles, deriveRewindDiffSummaries } from "./rewindFilesPreview"; import { ChatCursorCloudPanel, type ChatCursorCloudPanelHandle } from "./ChatCursorCloudPanel"; import { CursorCloudInlineLaunch, type CursorCloudInlineLaunchHandle } from "./CursorCloudInlineLaunch"; -import { QuickRunMenu } from "../run/QuickRunMenu"; +import { QuickRunInlineList } from "../run/QuickRunMenu"; import { ChatGitToolbar } from "./ChatGitToolbar"; import { LaneChip } from "../terminals/LaneChip"; import { getLaneAccent } from "../lanes/laneColorPalette"; @@ -121,6 +120,8 @@ import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel"; +import { CodexPlanCard } from "./codex/CodexPlanCard"; +import { ChatPrPane } from "./ChatPrPane"; import { useAppStore } from "../../state/appStore"; import { buildChatAppearanceRootStyle } from "./chatAppearance"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; @@ -338,11 +339,10 @@ function draftLaunchKindLabel(kind: DraftLaunchKind): string { return kind === "cli" ? "CLI session" : "chat"; } -function draftLaunchJobLabel(job: DraftLaunchJob): string { - if (job.status === "naming-lane" || job.status === "creating-lane") return "Auto-create lane"; - if (job.status === "failed") return "Launch failed"; - if (job.status === "ready") return job.mode === "background" ? "Background launch" : "Ready"; - return job.draftKind === "cli" ? "CLI launch" : "Chat launch"; +function draftLaunchPromptSnippet(job: DraftLaunchJob): string { + const text = job.snapshot.text.trim().replace(/\s+/g, " "); + if (!text) return job.title; + return text.length > 44 ? `${text.slice(0, 44)}…` : text; } function draftLaunchJobMessage(job: DraftLaunchJob): string { @@ -361,6 +361,33 @@ function staleDraftLaunchJobMessage(job: DraftLaunchJob): string { return `${draftLaunchJobMessage(job)} Still working. You can hide this status while ADE continues in the background.`; } +/** + * 3-quadrant reserve. The chat reserves horizontal space for whichever floating + * side panes are open (when there is room), so the centered transcript + composer + * re-center in the remaining area rather than leaving an empty gutter opposite an + * open pane. On a narrow surface it stops reserving so the chat keeps full width + * (the pane then overlays). Right is preferred over left when space is tight. + */ +const PANE_RESERVE_RIGHT_PX = 300; // ~18rem pane + margins +const PANE_RESERVE_LEFT_PX = 284; // ~17rem pane + margins +const CHAT_MIN_WIDTH_PX = 360; // recenter the chat as soon as a normal screen allows +function computePaneReserve( + width: number, + leftOpen: boolean, + rightOpen: boolean, +): { left: string; right: string } { + if (width <= 0) return { left: "0px", right: "0px" }; + let right = 0; + if (rightOpen && width - PANE_RESERVE_RIGHT_PX >= CHAT_MIN_WIDTH_PX) { + right = PANE_RESERVE_RIGHT_PX; + } + let left = 0; + if (leftOpen && width - right - PANE_RESERVE_LEFT_PX >= CHAT_MIN_WIDTH_PX) { + left = PANE_RESERVE_LEFT_PX; + } + return { left: `${left}px`, right: `${right}px` }; +} + type AiStatusSnapshot = AiSettingsStatus & { runtimeConnections?: Record; }; @@ -2295,6 +2322,11 @@ export function AgentChatPane({ onOpenShellSession, availableLanes, onLaneChange, + onToggleSessionsPane, + sessionsPaneCollapsed, + sessionsPaneCount, + onToggleToolsPane, + toolsPaneOpen, }: { laneId: string | null; laneLabel?: string | null; @@ -2335,6 +2367,13 @@ export function AgentChatPane({ availableLanes?: Array<{ id: string; name: string; color?: string | null; branchRef?: string | null; laneType?: string | null }>; /** Callback when lane selection changes in empty state */ onLaneChange?: (laneId: string) => void; + /** Work tab: far-left session-list expander rendered in this chat's header. */ + onToggleSessionsPane?: () => void; + sessionsPaneCollapsed?: boolean; + sessionsPaneCount?: number; + /** Work tab: far-right Tools-pane toggle rendered in this chat's header. */ + onToggleToolsPane?: () => void; + toolsPaneOpen?: boolean; }) { const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); const projectTransition = useAppStore((s) => s.projectTransition); @@ -2519,6 +2558,32 @@ export function AgentChatPane({ const [chatActionsOpen, setChatActionsOpen] = useState( () => readChatCompanionUiState(initialCompanionStateKey).chatActionsOpen, ); + // Left PR floating pane (ADE chats only). Session-scoped UI state; not persisted. + const [prPaneOpen, setPrPaneOpen] = useState(false); + // Auto-open the PR pane when this lane's PR transitions to opened / closed / + // merged (otherwise it follows normal toggle rules). + const prevPrStateRef = useRef(null); + useEffect(() => { + if (!laneId) { + prevPrStateRef.current = null; + return; + } + let cancelled = false; + window.ade.prs.getForLane(laneId) + .then((pr) => { if (!cancelled) prevPrStateRef.current = pr?.state ?? null; }) + .catch(() => {}); + const unsubscribe = window.ade.prs.onEvent((event) => { + if (event.type !== "prs-updated") return; + const nextState = event.prs.find((pr) => pr.laneId === laneId)?.state ?? null; + if (nextState !== prevPrStateRef.current) { + if (nextState === "open" || nextState === "closed" || nextState === "merged") { + setPrPaneOpen(true); + } + prevPrStateRef.current = nextState; + } + }); + return () => { cancelled = true; unsubscribe(); }; + }, [laneId]); const [chatActionsTab, setChatActionsTab] = useState( () => readChatCompanionUiState(initialCompanionStateKey).chatActionsTab, ); @@ -2662,6 +2727,18 @@ export function AgentChatPane({ const [parallelLaunchBusy, setParallelLaunchBusy] = useState(false); const [parallelLaunchStatus, setParallelLaunchStatus] = useState(null); const shellRef = useRef(null); + // Measure the chat surface width to drive the 3-quadrant pane reserve. + const [chatAreaWidth, setChatAreaWidth] = useState(0); + useEffect(() => { + const el = shellRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const next = entries[0]?.contentRect.width; + if (typeof next === "number") setChatAreaWidth(next); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); const composerMaxHeightPx = layoutVariant === "grid-tile" ? 144 : null; const sessionsRef = useRef(sessions); const completionSoundPrevTurnActiveRef = useRef(false); @@ -2944,6 +3021,13 @@ export function AgentChatPane({ const [subagentTranscriptLoading, setSubagentTranscriptLoading] = useState(false); const [subagentTranscriptUnsupported, setSubagentTranscriptUnsupported] = useState(false); + // Only take over the middle chat view when there's actually something to show + // (transcript content, or still loading). If a subagent has no transcript + // (e.g. Codex delegations / unsupported runtimes), we stay in the main chat + // instead of showing an ugly empty "Composer paused" takeover. + const subagentTakeoverActive = subagentView != null + && (subagentTranscriptLoading || (subagentTranscript?.length ?? 0) > 0); + useEffect(() => { if (!subagentView || !selectedSessionId) { setSubagentTranscript(null); @@ -5874,6 +5958,28 @@ export function AgentChatPane({ suppressDraftLaunchNavigation, ]); + // Robust foreground auto-open: a foreground send (Enter) opens the chat + // directly with no status banner. If the inline open was skipped because this + // pane remounted mid-launch, this effect opens the ready job from whichever + // instance is mounted. + useEffect(() => { + if (!forceDraft) return; + const job = draftLaunchJobs.find( + (entry) => entry.mode === "foreground" + && entry.status === "ready" + && entry.autoOpen + && Boolean(entry.laneId && entry.laneName && entry.sessionId), + ); + if (!job) return; + openLaunchedDraftSession({ + laneId: job.laneId!, + laneName: job.laneName!, + sessionId: job.sessionId!, + draftKind: job.draftKind, + jobId: job.id, + }); + }, [draftLaunchJobs, forceDraft, openLaunchedDraftSession]); + const resolveDraftLaunchLane = useCallback(async ( snapshot: DraftLaunchSnapshot, onAutoCreateNameResolved?: () => void, @@ -6151,7 +6257,10 @@ export function AgentChatPane({ laneName: launch.laneName, sessionId: launch.sessionId, draftKind: launch.draftKind, - autoOpen: false, + // Keep autoOpen set for foreground sends so the effect below can open the + // chat even if this pane instance remounted during the launch (otherwise + // the inline open is skipped and the job sits at "ready"). + autoOpen: mode === "foreground", }); if (!jobStillVisible) { return; @@ -6596,7 +6705,6 @@ export function AgentChatPane({ activeItemId: sid, selectedItemId: sid, draftKind: "chat", - viewMode: "tabs", }); } @@ -7663,6 +7771,21 @@ export function AgentChatPane({

Handoff is not available for this chat.

); + // Latest plan the runtime has proposed for this chat (plain scan — not a hook). + const latestPlanEvent = (() => { + for (let i = selectedEventsForDisplay.length - 1; i >= 0; i--) { + const evt = selectedEventsForDisplay[i]?.event; + if (evt && evt.type === "plan") return evt; + } + return null; + })(); + // Re-present the runtime's plan in the info pane (Plan tab) — a calm, readable + // companion to the live inline plan card. We never author or mutate the plan. + const chatActionsPlanContent = latestPlanEvent ? ( +
+ +
+ ) : null; const chatActionsPanelContent = ( +
+
Run
+

+ Start this lane’s process groups or open it in the Run / shell view. +

+
+ +
+ ) : undefined} /> ); const cursorCloudPanelContent = ( @@ -7844,15 +7979,6 @@ export function AgentChatPane({ ) : null} - {showWorkspaceChrome && laneId ? ( - - ) : null} {(showWorkspaceChrome && laneId) || canShowHandoff ? ( ) : null} {chatTerminalVisible ? setTerminalDrawerOpen((v) => !v)} /> : null} - {selectedSession?.provider === "codex" - && selectedSessionId - && selectedSession.threadId ? ( - { - // Open the ADE terminal drawer and write the resume command - // into the chat's active terminal pane (plan §D.1). Falls - // back to copying the command if the chat doesn't yet have an - // active terminal session. - setTerminalDrawerOpen(true); - const quoted = (parts: string[]) => - parts.map((p) => `'${p.replace(/'/g, "'\\''")}'`).join(" "); - const fullCommand = `cd ${quoted([args.cwd])} && ${quoted([args.binary, ...args.argv])}\r`; - void (async () => { - try { - const active = await window.ade.terminal.activeForChat({ - chatSessionId: selectedSessionId, - }); - if (active?.ptyId) { - await window.ade.terminal.write({ - ptyId: active.ptyId, - chatSessionId: selectedSessionId, - data: fullCommand, - }); - return; - } - } catch { - // fall through to clipboard - } - await navigator.clipboard.writeText(fullCommand.trimEnd()).catch(() => undefined); - })(); - }} - /> - ) : null} {resolvedChips.map((chip) => ( setPrPaneOpen((v) => !v) : undefined} + prPaneOpen={prPaneOpen} trailingActions={chatHeaderTrailingActions} + onToggleSessionsPane={onToggleSessionsPane} + sessionsPaneCollapsed={sessionsPaneCollapsed} + sessionsPaneCount={sessionsPaneCount} + onToggleToolsPane={onToggleToolsPane} + toolsPaneOpen={toolsPaneOpen} className="space-y-0 p-0" /> @@ -8521,7 +8619,7 @@ export function AgentChatPane({ // Composer placeholder shown when the chat is drilled in to a subagent // transcript. Replies always go to the parent session, so disabling input // here matches user expectations and the wireframe brief. - const subagentComposerLock = subagentView ? ( + const subagentComposerLock = subagentTakeoverActive ? (
) : null; + // Launch-status banners belong to the new-chat/draft surface only — never + // above the composer of an already-open chat. + // Only background launches, auto-create-lane (which starts in lane-creation + // states), and failures surface a status banner here — a normal foreground + // send (Enter) to the current lane opens the chat directly, no popup. + const visibleDraftLaunchJobs = forceDraft + ? draftLaunchJobs.filter((job) => + job.mode === "background" + || job.status === "failed" + || job.status === "naming-lane" + || job.status === "creating-lane") + : EMPTY_DRAFT_LAUNCH_JOBS; const composerWithTypographyRoot = (
- {draftLaunchJobs.map((job) => { + {visibleDraftLaunchJobs.map((job) => { const isFailed = job.status === "failed"; const isReady = job.status === "ready"; const isActiveJob = !isDraftLaunchJobTerminal(job.status); @@ -8572,43 +8682,33 @@ export function AgentChatPane({ data-testid="draft-launch-job" aria-live={isActiveJob ? "polite" : undefined} className={cn( - "flex items-start justify-between gap-3 rounded-lg border px-3 py-2.5 font-sans text-[11px] shadow-[0_10px_40px_rgba(0,0,0,0.10)]", - isFailed && "border-rose-300/20 bg-rose-500/[0.08] text-rose-100/90", - isReady && "border-emerald-300/20 bg-emerald-500/[0.08] text-emerald-100/90", - isActiveJob && "border-white/10 bg-white/[0.045] text-fg/75", + "mx-auto flex w-full max-w-[var(--chat-column,46rem)] items-center justify-between gap-3 rounded-lg border px-3 py-1.5 font-sans text-[length:calc(var(--chat-font-size)*11/14)]", + isFailed && "border-rose-300/20 bg-rose-500/[0.07] text-rose-100/90", + isReady && "border-emerald-300/18 bg-emerald-500/[0.06] text-emerald-100/85", + isActiveJob && "border-white/10 bg-white/[0.04] text-fg/70", )} > -
+
{isActiveJob ? ( - + ) : null} -
-
- - {draftLaunchJobLabel(job)} - - {job.title} -
-
- {isStaleActiveJob ? staleDraftLaunchJobMessage(job) : draftLaunchJobMessage(job)} -
-
+ + {isFailed + ? (job.error ? `Launch failed: ${job.error}` : "Launch failed.") + : isActiveJob + ? (isStaleActiveJob ? staleDraftLaunchJobMessage(job) : draftLaunchJobMessage(job)) + : ( + <> + Message sent: “{draftLaunchPromptSnippet(job)}” + + )} +
{isFailed ? ( ) : null} {(!isActiveJob || isStaleActiveJob) ? ( ) : null}
@@ -8668,13 +8761,22 @@ export function AgentChatPane({ const orchestrationRunId = selectedSession?.orchestrationRunId ?? null; const orchestrationRole = activeOrchestrationRole; const orchestrationPanelOpen = Boolean(orchestrationRunId); - const rightPaneOpen = chatActionsOpen || appPanelOpen || effectiveCursorCloudPaneOpen || orchestrationPanelOpen; + // Chat actions is an *info* pane: it floats over the right gutter created by + // the centered transcript (Codex / t3 reference) rather than squeezing the + // chat. The heavier working panels (App Control, iOS sim, Cursor Cloud, + // orchestration) keep the resizable split that pushes the chat column. + const heavyRightPaneOpen = appPanelOpen || effectiveCursorCloudPaneOpen || orchestrationPanelOpen; const supportsSplit = layoutVariant !== "grid-tile"; + const chatActionsFloating = chatActionsOpen && !heavyRightPaneOpen && supportsSplit; + // Reserve gutter space for the open floating panes so the chat re-centers in + // the remaining area (3-quadrant rebalance). Heavy split panes don't reserve. + const leftPaneActive = prPaneOpen && Boolean(laneId) && supportsSplit; + const paneReserve = computePaneReserve(chatAreaWidth, leftPaneActive, chatActionsFloating); const splitChatColStyle: React.CSSProperties | undefined = - rightPaneOpen && supportsSplit ? { flexGrow: 100 - rightPaneSplit } : undefined; + heavyRightPaneOpen && supportsSplit ? { flexGrow: 100 - rightPaneSplit } : undefined; const splitRightPaneStyle: React.CSSProperties | undefined = - rightPaneOpen && supportsSplit ? { flexGrow: rightPaneSplit, flexBasis: 0 } : undefined; - const rightPaneDivider = rightPaneOpen && supportsSplit ? ( + heavyRightPaneOpen && supportsSplit ? { flexGrow: rightPaneSplit, flexBasis: 0 } : undefined; + const rightPaneDivider = heavyRightPaneOpen && supportsSplit ? (
); + // Floating info-pane overlay (standard layout) — a neutral grey card anchored + // to the right gutter, à la the Codex desktop "Environment" pane. Deliberately + // NOT provider-tinted (avoids over-coloring the surface). + // Panes fade in/out (sliding from the gutters read as disorienting). Compact, + // neutral sidebar-colored cards — no purple, no big empty boxes (Codex pane). + const FLOATING_PANE_FADE = { duration: 0.16, ease: [0.4, 0, 0.2, 1] as const }; + const FLOATING_PANE_CARD_CLASS = + "flex min-h-0 w-full flex-col overflow-hidden rounded-xl border border-white/[0.07] bg-[color:var(--work-sidebar-bg,#161618)] shadow-[0_20px_60px_-30px_rgba(0,0,0,0.8)]"; + const renderFloatingPane = (content: React.ReactNode) => ( + +
+ {content} +
+
+ ); + // Left gutter floating pane (PR) — same compact neutral card, anchored left. + const renderFloatingLeftPane = (content: React.ReactNode) => ( + +
+ {content} +
+
+ ); + // Orchestration plan panel — mounted whenever the active session has a // runId. Lead view is fully interactive; worker/validator view is read-only. const orchestrationPanelContent = orchestrationRunId ? ( @@ -8734,6 +8874,8 @@ export function AgentChatPane({ setSubagentView(null)} @@ -8935,19 +9077,19 @@ export function AgentChatPane({ ) : null} {rightPaneDivider} - {chatActionsOpen ? renderRightPane(chatActionsPanelContent) : null} + + {chatActionsOpen && chatActionsFloating + ? renderFloatingPane(chatActionsPanelContent) + : null} + + {chatActionsOpen && !chatActionsFloating + ? renderRightPane(chatActionsPanelContent) + : null} + + {prPaneOpen && laneId && supportsSplit + ? renderFloatingLeftPane( + setPrPaneOpen(false)} + />, + ) + : null} + {effectiveIosSimulatorOpen ? renderRightPane(iosSimulatorPanelContent) : null} {effectiveAppControlOpen ? renderRightPane(appControlPanelContent) : null} {effectiveCursorCloudPaneOpen ? renderRightPane(cursorCloudPanelContent) : null} diff --git a/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx index 428112fde..fc704936d 100644 --- a/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx @@ -1,33 +1,44 @@ import type { ReactNode } from "react"; -import { ArrowBendUpRight, Cube, TreeStructure, X } from "@phosphor-icons/react"; +import { ArrowBendUpRight, Cube, ListChecks, Play, TreeStructure, X } from "@phosphor-icons/react"; import { GlowMenu, type GlowMenuItem } from "../ui/GlowMenu"; -export type ChatActionsTab = "agents" | "proof" | "handoff"; +export type ChatActionsTab = "agents" | "proof" | "handoff" | "plan" | "run"; + +// The drawer tabs use GlowMenu's `neutral` mode, which renders every tab with +// the same muted-grey-→-bright-fg treatment plus one shared violet indicator. +// The `gradient`/`color` fields are required by GlowMenuItem but are ignored in +// neutral mode, so they're set to a single neutral placeholder here (NOT a +// per-tab saturated palette) to make that intent explicit. +const NEUTRAL_INDICATOR = { + gradient: "transparent", + color: "currentColor", +} as const; const CHAT_ACTIONS_TABS: Array> = [ - { - 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", - }, + { id: "agents", label: "Agents", icon: TreeStructure, ...NEUTRAL_INDICATOR }, + { id: "proof", label: "Proof", icon: Cube, ...NEUTRAL_INDICATOR }, + { id: "handoff", label: "Handoff", icon: ArrowBendUpRight, ...NEUTRAL_INDICATOR }, ]; +// Plan tab is surfaced only when the active chat has a plan to show — it leads +// the tab strip so a freshly proposed plan is one glance away (the runtime's +// native plan instructions are untouched; we only re-present the markdown here). +const PLAN_TAB: GlowMenuItem = { + id: "plan", + label: "Plan", + icon: ListChecks, + ...NEUTRAL_INDICATOR, +}; + +// Run tab (moved off the header) — surfaced only when the chat has a lane that +// can run process groups. +const RUN_TAB: GlowMenuItem = { + id: "run", + label: "Run", + icon: Play, + ...NEUTRAL_INDICATOR, +}; + export function ChatActionsDrawerPanel({ tab, onTabChange, @@ -35,6 +46,8 @@ export function ChatActionsDrawerPanel({ agentsContent, proofContent, handoffContent, + planContent, + runContent, }: { tab: ChatActionsTab; onTabChange: (tab: ChatActionsTab) => void; @@ -42,22 +55,42 @@ export function ChatActionsDrawerPanel({ agentsContent: ReactNode; proofContent: ReactNode; handoffContent: ReactNode; + planContent?: ReactNode; + runContent?: ReactNode; }) { - const body = tab === "agents" ? agentsContent : tab === "proof" ? proofContent : handoffContent; + const tabs = [ + ...(planContent ? [PLAN_TAB] : []), + ...CHAT_ACTIONS_TABS, + ...(runContent ? [RUN_TAB] : []), + ]; + // Fall back off tabs that aren't currently available. + const activeTab: ChatActionsTab = + (tab === "plan" && !planContent) || (tab === "run" && !runContent) ? "agents" : tab; + const bodyByTab: Record = { + plan: planContent, + run: runContent, + agents: agentsContent, + proof: proofContent, + handoff: handoffContent, + }; + const body = bodyByTab[activeTab]; return ( -
-
+ // Transparent wrapper — the floating pane's own neutral background shows + // through; we add no surface of our own here. +
+
); - }, [linkedPr, prMenuOpen]); + }, [linkedPr, prPillActive, onTogglePrPane]); // Slide-out panel that appears to the right of the PR badge when toggled. const prMenu = useMemo(() => { @@ -379,12 +394,8 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ return (
- {/* Dirty count badge */} - {dirtyCount > 0 ? ( - - {dirtyCount} - - ) : null} + {/* Files-changed (dirty count) badge intentionally removed from the header — + the Git actions pane in the Tools sidebar already surfaces this. */} {/* PR badge or create button. When the badge is open it expands into a slide-out with action buttons + live PR status preview. */} @@ -396,7 +407,12 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({
) : ( - diff --git a/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx b/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx new file mode 100644 index 000000000..56eac988e --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx @@ -0,0 +1,238 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { AnimatePresence, motion } from "motion/react"; +import { CircleNotch, GitPullRequest, Warning } from "@phosphor-icons/react"; +import { BranchIcon } from "../ui/vcsIcons"; +import { useAppStore } from "../../state/appStore"; +import type { GitBranchSummary } from "../../../shared/types"; +import { resolveLaneBaseBranch } from "../prs/shared/laneBranchTargets"; + +/** + * Compact inline pull-request creator embedded directly in the left PR + * floating pane. Replaces the route-away "Create pull request" handoff: the + * user picks a target branch + title and submits without leaving the Work tab. + * + * On success we rely on the parent ChatPrPane's `prs.onEvent` subscription to + * swap to the PR-details view, so this component only triggers creation. A tiny + * "Open full composer" link routes power users to the full modal in the PRs + * tab. Background stays transparent so the floating pane's sidebar tone shows + * through — neutral greys + one violet accent, matching the shared pane design. + */ + +const fieldLabel = + "block text-[10px] font-medium uppercase tracking-wide text-fg/40"; + +const inputBase = + "w-full rounded-lg border border-white/[0.07] bg-white/[0.03] px-2.5 py-1.5 text-[12px] text-fg/85 outline-none transition-colors placeholder:text-fg/30 focus:border-violet-400/40 focus:bg-white/[0.05]"; + +export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ + laneId, + branchName, +}: { + laneId: string; + branchName?: string | null; +}) { + const navigate = useNavigate(); + const lanes = useAppStore((s) => s.lanes); + + const lane = useMemo(() => lanes.find((l) => l.id === laneId) ?? null, [lanes, laneId]); + const primaryLane = useMemo(() => lanes.find((l) => l.laneType === "primary") ?? null, [lanes]); + + const defaultBase = useMemo( + () => + resolveLaneBaseBranch({ + lane, + lanes, + primaryBranchRef: primaryLane?.branchRef ?? null, + }), + [lane, lanes, primaryLane?.branchRef], + ); + + const [baseBranch, setBaseBranch] = useState(""); + const [title, setTitle] = useState(""); + const [branches, setBranches] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const baseTouchedRef = useRef(false); + const titleTouchedRef = useRef(false); + + // Default the target branch to the lane's resolved base until the user edits it. + useEffect(() => { + if (baseTouchedRef.current) return; + if (defaultBase) setBaseBranch(defaultBase); + }, [defaultBase]); + + // Default the title to the lane name (fallback: branch name) until edited. + useEffect(() => { + if (titleTouchedRef.current) return; + const fallback = lane?.name?.trim() || branchName?.trim() || ""; + if (fallback) setTitle(fallback); + }, [lane?.name, branchName]); + + // Load branch list for the target-branch picker (same source the modal uses). + useEffect(() => { + const sourceLaneId = primaryLane?.id ?? laneId; + if (!sourceLaneId) return; + let cancelled = false; + window.ade.git + .listBranches({ laneId: sourceLaneId }) + .then((list) => { + if (!cancelled) setBranches(list); + }) + .catch(() => { + if (!cancelled) setBranches([]); + }); + return () => { + cancelled = true; + }; + }, [primaryLane?.id, laneId]); + + const branchOptions = useMemo(() => { + const seen = new Set(); + const options: string[] = []; + for (const b of branches) { + const name = b.isRemote ? b.name.replace(/^[^/]+\//, "") : b.name; + if (!seen.has(name)) { + seen.add(name); + options.push(name); + } + } + return options.sort((a, b) => a.localeCompare(b)); + }, [branches]); + + const compare = lane?.status ?? null; + + const handleCreate = useCallback(async () => { + setBusy(true); + setError(null); + try { + const trimmedTitle = title.trim(); + const trimmedBase = baseBranch.trim(); + await window.ade.prs.createFromLane({ + laneId, + title: trimmedTitle || lane?.name || branchName || "PR", + body: "", + draft: false, + ...(trimmedBase ? { baseBranch: trimmedBase } : {}), + }); + // Success: ChatPrPane's prs.onEvent subscription swaps to the details + // view automatically, so we leave busy=true until this unmounts. + } catch (err: unknown) { + const raw = err instanceof Error ? err.message : String(err); + setError(raw.replace(/^Error invoking remote method '[^']+': (?:Error: )?/, "")); + setBusy(false); + } + }, [baseBranch, branchName, lane?.name, laneId, title]); + + const openFullComposer = useCallback(() => { + const params = new URLSearchParams({ tab: "normal", create: "1", sourceLaneId: laneId, target: "primary" }); + navigate(`/prs?${params.toString()}`); + }, [laneId, navigate]); + + return ( +
+

No pull request yet — open one for this lane.

+ +
+ +
+ + { + baseTouchedRef.current = true; + setBaseBranch(e.target.value); + }} + disabled={busy} + placeholder="main" + spellCheck={false} + className={`${inputBase} pl-7 font-mono`} + /> + + {branchOptions.map((name) => ( + +
+
+ +
+ + { + titleTouchedRef.current = true; + setTitle(e.target.value); + }} + disabled={busy} + placeholder={lane?.name ?? branchName ?? "Pull request title"} + className={inputBase} + /> +
+ + {compare && (compare.ahead > 0 || compare.behind > 0 || compare.dirty) ? ( +
+ {compare.ahead} ahead + {compare.behind} behind + + {compare.dirty ? "dirty" : "clean"} + +
+ ) : null} + + + {error ? ( + +
+ + {error} +
+
+ ) : null} +
+ + + + +
+ ); +}); + +export default ChatPrInlineCreator; diff --git a/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx b/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx new file mode 100644 index 000000000..4b0e2df43 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx @@ -0,0 +1,202 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + ArrowSquareOut, + CheckCircle, + Clock, + Copy, + Check, + GithubLogo, + GitPullRequest, + X, + XCircle, +} from "@phosphor-icons/react"; +import { cn } from "../ui/cn"; +import type { PrSummary } from "../../../shared/types"; +import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; +import { ChatPrInlineCreator } from "./ChatPrInlineCreator"; + +/** + * Left floating info-pane for an ADE chat's pull request. Mirrors the right + * Chat-actions pane: when a PR exists we show its live status + quick actions; + * when none exists we embed a compact inline PR creator (ChatPrInlineCreator) + * so the user never leaves the Work tab. CLI sessions keep the inline pill menu + * — this pane is only wired when AgentChatPane passes the toggle. + */ + +const paneAction = + "inline-flex w-full items-center gap-2 rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-1.5 text-left text-[12px] font-medium text-fg/65 transition-colors hover:border-white/[0.10] hover:bg-white/[0.04] hover:text-fg/85"; + +function stateTone(state: PrSummary["state"]): { dot: string; label: string } { + switch (state) { + case "open": return { dot: "bg-emerald-400", label: "Open" }; + case "draft": return { dot: "bg-amber-400/70", label: "Draft" }; + case "merged": return { dot: "bg-violet-400", label: "Merged" }; + case "closed": return { dot: "bg-red-400/70", label: "Closed" }; + default: return { dot: "bg-fg/25", label: String(state) }; + } +} + +function ChecksLabel({ status }: { status: PrSummary["checksStatus"] }) { + switch (status) { + case "passing": + return Checks passing; + case "failing": + return Checks failing; + case "pending": + return Checks running; + default: + return null; + } +} + +function PrDetails({ + pr, + copied, + onOpenAde, + onOpenGitHub, + onCopy, +}: { + pr: PrSummary; + copied: boolean; + onOpenAde: () => void; + onOpenGitHub: () => void; + onCopy: () => void; +}) { + const tone = stateTone(pr.state); + return ( +
+
+ + {tone.label} + {formatPrBadgeLabel(pr)} +
+

{pr.title}

+
+ + {pr.additions > 0 || pr.deletions > 0 ? ( + + +{pr.additions} + −{pr.deletions} + + ) : null} +
+
+ + + +
+
+ ); +} + +export const ChatPrPane = React.memo(function ChatPrPane({ + laneId, + branchName, + onClose, +}: { + laneId: string; + branchName?: string | null; + onClose: () => void; +}) { + const navigate = useNavigate(); + const [pr, setPr] = useState(null); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + + const refresh = useCallback(async () => { + try { + setPr(await window.ade.prs.getForLane(laneId)); + } catch { + setPr(null); + } finally { + setLoading(false); + } + }, [laneId]); + + useEffect(() => { void refresh(); }, [refresh]); + + useEffect(() => { + const unsubscribe = window.ade.prs.onEvent((event) => { + if (event.type === "prs-updated") void refresh(); + }); + return unsubscribe; + }, [refresh]); + + useEffect(() => { + if (!copied) return; + const id = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(id); + }, [copied]); + + const openInAde = useCallback(() => { + if (!pr) return; + navigate(`/prs?tab=normal&prId=${encodeURIComponent(pr.id)}`); + }, [pr, navigate]); + + const openInGitHub = useCallback(async () => { + if (!pr) return; + try { + await window.ade.prs.openInGitHub(pr.id); + } catch { + try { window.open(pr.githubUrl, "_blank", "noopener,noreferrer"); } catch { /* noop */ } + } + }, [pr]); + + const copyLink = useCallback(async () => { + if (!pr) return; + try { + await navigator.clipboard.writeText(pr.githubUrl); + setCopied(true); + } catch { + /* clipboard denied */ + } + }, [pr]); + + return ( +
+
+ + + Pull request + + +
+
+ {loading ? ( +

Loading…

+ ) : pr ? ( + void openInGitHub()} + onCopy={() => void copyLink()} + /> + ) : ( + + )} +
+
+ ); +}); + +export default ChatPrPane; diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 92e4ef69a..2357ca4f7 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -3,8 +3,6 @@ import { Check, Circle, CircleHalf, - CircleNotch, - MinusCircle, StopCircle, TreeStructure, X, @@ -19,69 +17,130 @@ import { CodexGoalCard } from "./codex/CodexGoalCard"; /* ── Formatting helpers ── */ -function formatTokenCount(value: number | null | undefined): string | null { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; - return String(Math.round(value)); -} - function formatDurationMs(value: number | null | undefined): string | null { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; if (value >= 60_000) return `${Math.round(value / 60_000)}m`; return `${Math.max(1, Math.round(value / 1000))}s`; } -function runtimeText(snapshot: ChatSubagentSnapshot): string | null { - const elapsedMs = snapshot.usage?.durationMs - ?? Math.max(0, Date.parse(snapshot.updatedAt) - Date.parse(snapshot.startedAt)); - const durationText = formatDurationMs(elapsedMs); - const tokenText = formatTokenCount(snapshot.usage?.totalTokens); - const parts = [snapshot.lastToolName, durationText, tokenText ? `${tokenText} tok` : null] - .filter((part): part is string => Boolean(part)); - return parts.length ? parts.join(" · ") : null; +// Deterministic per-agent identity color. Real persona names/colors are not on +// any provider wire, so we derive a stable hue from the agent id purely for +// visual distinction (à la the Codex desktop colored agent glyphs). +const AGENT_IDENTITY_COLORS = [ + "#e9a6a6", "#a6cfe9", "#b6e0aa", "#e6cba0", "#c8b0e6", "#eebfdc", "#9fe0d6", "#e0d79f", +]; +function agentColor(id: string): string { + let hash = 0; + for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0; + return AGENT_IDENTITY_COLORS[hash % AGENT_IDENTITY_COLORS.length]!; } -/* ── Glyphs (14 px monoline, single visual family) ── +/* ── Per-agent identity glyph ── + * + * Codex's Environment > Subagents list gives every agent a tiny distinct + * logo/glyph. There are no real persona logos on any provider wire, so we + * synthesise a deterministic 3×3 geometric identicon from the agent id — a + * mirrored-grid "mini robot face" that is stable per agent and visually + * distinct from its neighbours, tinted with the same agentColor() hash. * - * One stroke weight, one diameter, one baseline. Status drives color; category - * (subagent vs background) drives a slight tint on running rows so the eye can - * separate them without leaning on text alone. Stopped uses MinusCircle so it - * does NOT collide with the "never started" empty circle reading. + * Status is layered on top: a small badge in the corner (check / cross / + * halted) and, while running, a subtle animated ring around the glyph — so the + * spinner survives but never takes over the whole row. */ const GLYPH_SIZE = 16; type GlyphCategory = "subagent" | "background"; -function StatusGlyph({ +// Cheap stable hash; independent from the color hash so shape ≠ hue lockstep. +function glyphHash(id: string): number { + let hash = 2166136261; + for (let i = 0; i < id.length; i++) { + hash ^= id.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +// Deterministic mirrored 3×3 identicon (5 unique cells, left+center+mirrored +// right) → a small symmetric "face". Distinct per id, always legible at 16 px. +function identiconCells(id: string): boolean[] { + const hash = glyphHash(id); + // Force at least one lit cell so empty glyphs never happen. + const left = [0, 1, 2].map((i) => Boolean((hash >> i) & 1)); + const center = [3, 4, 5].map((i) => Boolean((hash >> i) & 1)); + if (!left.some(Boolean) && !center.some(Boolean)) center[1] = true; + // grid order: row-major 3×3, columns [left, center, mirror-of-left] + return [ + left[0]!, center[0]!, left[0]!, + left[1]!, center[1]!, left[1]!, + left[2]!, center[2]!, left[2]!, + ]; +} + +function AgentGlyph({ + id, + color, status, - category = "subagent", }: { + id: string; + color: string; status: ChatSubagentSnapshot["status"]; - category?: GlyphCategory; }) { - if (status === "running") { - const tint = category === "background" - ? "text-cyan-300/85" - : "text-[color:var(--color-accent,#A78BFA)]"; - return ( - + {isRunning ? ( + + ) : null} + - ); - } - if (status === "completed") { - return ; - } - if (status === "failed") { - return ; - } - // stopped — visibly distinct from "pending/never started" - return ; + width={18} + height={18} + viewBox="0 0 18 18" + className={cn("rounded-[4px]", dimmed && "opacity-55")} + style={{ backgroundColor: `color-mix(in srgb, ${color} 14%, transparent)` }} + > + {cells.map((lit, i) => { + if (!lit) return null; + const col = i % 3; + const row = Math.floor(i / 3); + return ( + + ); + })} + + {status === "completed" ? ( + + ) : null} + {status === "failed" ? ( + + ) : null} + + ); } function PlanGlyph({ status }: { status: ChatInfoPlanStep["status"] }) { @@ -127,23 +186,21 @@ function SectionHeader({ emphasized?: boolean; }) { return ( -
+
{label} {hint ? ( - + {hint} ) : null} @@ -167,9 +224,10 @@ function ProgressBar({ percent }: { percent: number }) { /* ── Row ── * - * One line per row. Glyph sits in a fixed 12 px column at x=16 so every - * section shares the same left rail. Hover wash is 2.5 %; selected rows get a - * 1 px accent rail in the left padding gutter (no chip, no card chrome). + * Codex-style compact list row: per-agent identicon glyph on the left, name, + * then status + elapsed time on the right. No card chrome, no saturated fill — + * the row hugs its content with only a faint hover wash. Selection is a subtle + * violet ring/tint, nothing more. */ // Some runtimes stamp a placeholder agentType on the wire (e.g. legacy OpenCode @@ -185,6 +243,21 @@ function meaningfulName(snapshot: ChatSubagentSnapshot): string { return snapshot.agentId ?? snapshot.taskId; } +const STATUS_LABEL: Record = { + running: "running", + completed: "done", + failed: "failed", + stopped: "halted", +}; + +// Elapsed-time chip for the right rail — duration only (no tool/token noise), +// so the compact row stays scannable. +function elapsedText(snapshot: ChatSubagentSnapshot): string | null { + const elapsedMs = snapshot.usage?.durationMs + ?? Math.max(0, Date.parse(snapshot.updatedAt) - Date.parse(snapshot.startedAt)); + return formatDurationMs(elapsedMs); +} + function SubagentRow({ snapshot, selected, @@ -196,18 +269,17 @@ function SubagentRow({ category: GlyphCategory; onClick: () => void; }) { - const runtime = runtimeText(snapshot); const name = meaningfulName(snapshot); + const color = agentColor(snapshot.agentId ?? snapshot.taskId); const isRunning = snapshot.status === "running"; const isCompleted = snapshot.status === "completed"; const isStopped = snapshot.status === "stopped"; const isFailed = snapshot.status === "failed"; + const time = elapsedText(snapshot); + const statusLabel = STATUS_LABEL[snapshot.status]; const runningLabelTint = category === "background" - ? "text-cyan-100/95" + ? "text-cyan-100/90" : "text-[color:var(--color-accent-bright,#C4B5FD)]"; - const runningRailColor = category === "background" - ? "bg-cyan-300/55" - : "bg-[color:var(--color-accent,#A78BFA)]/55"; return ( ); } @@ -420,7 +492,7 @@ export function ChatSubagentsPanel({ emphasized /> {foreground.length ? ( -
+
{foreground.map((snap) => ( ) : ( -

+

None active.

)} @@ -442,7 +514,7 @@ export function ChatSubagentsPanel({ {background.length ? (
-
+
{background.map((snap) => ( {body} diff --git a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx index 8c6954e7b..509ab8483 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx @@ -27,6 +27,8 @@ export function ChatSurfaceShell({ shellGeometry = "default", /** When true, shell grows with content (e.g. settings live preview) instead of filling a fixed-height parent. */ autoHeight = false, + paneReserveLeft = "0px", + paneReserveRight = "0px", }: { mode: ChatSurfaceMode; accentColor?: string | null; @@ -42,6 +44,9 @@ export function ChatSurfaceShell({ chromeTint?: ChatChromeTint; shellGeometry?: ChatShellGeometry; autoHeight?: boolean; + /** Horizontal space the chat reserves for open floating side panes (CSS length). */ + paneReserveLeft?: string; + paneReserveRight?: string; }) { const scale = Number.isFinite(contentScale) && contentScale > 0 ? contentScale : 1; const scaled = Math.abs(scale - 1) > 0.001; @@ -76,7 +81,7 @@ export function ChatSurfaceShell({ "relative w-full min-w-0 max-w-full overflow-hidden px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-0 sm:px-3 sm:pb-2", footerClassName, )} - style={{ background: "var(--color-bg)" }} + style={{ background: "var(--chat-canvas-bg)" }} > {footer}
@@ -104,8 +109,10 @@ export function ChatSurfaceShell({ )} style={{ ...chatSurfaceVars(mode, accentColor, { chromeTint }), - background: "var(--color-bg)", - }} + background: "var(--chat-canvas-bg)", + ["--chat-pane-reserve-left" as string]: paneReserveLeft, + ["--chat-pane-reserve-right" as string]: paneReserveRight, + } as CSSProperties} > {scaled ? (
diff --git a/apps/desktop/src/renderer/components/chat/chatAppearance.ts b/apps/desktop/src/renderer/components/chat/chatAppearance.ts index 273c82e60..b3e3987da 100644 --- a/apps/desktop/src/renderer/components/chat/chatAppearance.ts +++ b/apps/desktop/src/renderer/components/chat/chatAppearance.ts @@ -46,7 +46,7 @@ export function transcriptBubblePaddingPx(density: ChatTranscriptDensity): { case "spacious": return { userX: 20, userY: 14, assistantX: 26, assistantY: 22 }; default: - return { userX: 16, userY: 10, assistantX: 20, assistantY: 16 }; + return { userX: 16, userY: 8, assistantX: 20, assistantY: 16 }; } } diff --git a/apps/desktop/src/renderer/components/chat/chatMarkdown.tsx b/apps/desktop/src/renderer/components/chat/chatMarkdown.tsx index 0e90b4a10..52bdb38ad 100644 --- a/apps/desktop/src/renderer/components/chat/chatMarkdown.tsx +++ b/apps/desktop/src/renderer/components/chat/chatMarkdown.tsx @@ -86,22 +86,22 @@ export function buildChatMarkdownComponents(tone: Tone = "sky", overrides: Overr ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , h1: ({ children }) => ( -

    +

    {children}

    ), h2: ({ children }) => ( -

    +

    {children}

    ), h3: ({ children }) => ( -

    +

    {children}

    ), h4: ({ children }) => ( -

    +

    {children}

    ), diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx deleted file mode 100644 index 5287c3654..000000000 --- a/apps/desktop/src/renderer/components/chat/codex/CodexOpenInCliButton.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { ArrowSquareOut, Terminal } from "@phosphor-icons/react"; -import { cn } from "../../ui/cn"; - -type CodexOpenInCliButtonProps = { - sessionId: string; - /** - * Called when the user picks "In ADE terminal". The button does the IPC call to - * resolve the resume command; the parent is responsible for revealing the - * built-in terminal drawer and feeding the command into it. The path is the - * chat lane's worktree. - */ - onUseAdeTerminal: (args: { - binary: string; - argv: string[]; - cwd: string; - threadId: string; - copyThreadIdToClipboard: boolean; - }) => void; -}; - -export function CodexOpenInCliButton({ sessionId, onUseAdeTerminal }: CodexOpenInCliButtonProps) { - const [open, setOpen] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [toast, setToast] = useState(null); - const containerRef = useRef(null); - - useEffect(() => { - if (!open) return; - const onDocClick = (e: MouseEvent) => { - if (!containerRef.current) return; - if (containerRef.current.contains(e.target as Node)) return; - setOpen(false); - }; - const onEsc = (e: KeyboardEvent) => { - if (e.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", onDocClick); - document.addEventListener("keydown", onEsc); - return () => { - document.removeEventListener("mousedown", onDocClick); - document.removeEventListener("keydown", onEsc); - }; - }, [open]); - - useEffect(() => { - if (!toast) return; - const t = window.setTimeout(() => setToast(null), 4000); - return () => window.clearTimeout(t); - }, [toast]); - - useEffect(() => { - if (!error) return; - const t = window.setTimeout(() => setError(null), 4000); - return () => window.clearTimeout(t); - }, [error]); - - const handleNewWindow = useCallback(async () => { - setOpen(false); - setError(null); - setBusy(true); - try { - const result = await window.ade.agentChat.codex.openInCli({ - sessionId, - mode: "new-window", - }); - if (result.copyThreadIdToClipboard) { - await navigator.clipboard.writeText(result.threadId).catch(() => undefined); - setToast("Thread ID copied — paste into Codex's Ctrl+R picker"); - } else { - setToast("Opened in a new terminal"); - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - }, [sessionId]); - - const handleAdeTerminal = useCallback(async () => { - setOpen(false); - setError(null); - setBusy(true); - try { - const result = await window.ade.agentChat.codex.openInCli({ - sessionId, - mode: "ade-terminal", - }); - if (result.copyThreadIdToClipboard) { - await navigator.clipboard.writeText(result.threadId).catch(() => undefined); - setToast("Thread ID copied — paste into Codex's Ctrl+R picker"); - } - onUseAdeTerminal({ - binary: result.binary, - argv: result.argv, - cwd: result.cwd, - threadId: result.threadId, - copyThreadIdToClipboard: result.copyThreadIdToClipboard, - }); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - }, [sessionId, onUseAdeTerminal]); - - return ( -
    - - - {open ? ( -
    - -
    - -
    - ) : null} - - {toast ? ( -
    - {toast} -
    - ) : null} - {error ? ( -
    - {error} -
    - ) : null} -
    - ); -} - -export default CodexOpenInCliButton; diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index ba617b38f..0b2e4c77b 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -24,10 +24,7 @@ export function LaneWorkPane({ useEffect(() => { if (!laneId) return; - const hasVisibleTerminalSurface = - work.viewMode === "grid" - ? work.visibleSessions.length > 0 - : Boolean(work.activeItemId); + const hasVisibleTerminalSurface = Boolean(work.activeItemId); if (!hasVisibleTerminalSurface) return; const raf = window.requestAnimationFrame(() => { @@ -40,7 +37,7 @@ export function LaneWorkPane({ window.cancelAnimationFrame(raf); window.clearTimeout(settleTimer); }; - }, [laneId, work.activeItemId, work.viewMode, visibleSessionIdsKey]); + }, [laneId, work.activeItemId, visibleSessionIdsKey]); if (!laneId) { return ( @@ -54,15 +51,12 @@ export function LaneWorkPane({
    { openItemIds: ["session-1", "session-2"], activeItemId: "session-2", selectedItemId: "session-2", - viewMode: "grid", draftKind: "cli", laneFilter: "all", statusFilter: "all", @@ -768,7 +767,6 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { ...previousState, activeItemId: null, selectedItemId: null, - viewMode: "tabs", draftKind: "chat", }); }); diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index efe832b17..d743c8e4b 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; -import { useAppStore, type WorkDraftKind, type WorkProjectViewState, type WorkViewMode } from "../../state/appStore"; +import { useAppStore, type WorkDraftKind, type WorkProjectViewState } from "../../state/appStore"; import { listSessionsCached, invalidateSessionListCache } from "../../lib/sessionListCache"; import { sessionStatusBucket } from "../../lib/terminalAttention"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; @@ -17,7 +17,8 @@ const EMPTY_WORK_STATE: WorkProjectViewState = { openItemIds: [], activeItemId: null, selectedItemId: null, - viewMode: "tabs", + gridSets: [], + activeGridSetId: null, draftKind: "chat", draftLaneId: null, laneFilter: "all", @@ -562,15 +563,10 @@ export function useLaneWorkSessions(laneId: string | null) { openSessionTab(session.id); }, [focusedSessionId, laneId, openSessionTab, sessionsById]); - const setViewMode = useCallback((nextMode: WorkViewMode) => { - setViewState({ viewMode: nextMode }); - }, [setViewState]); - const showDraftKind = useCallback((nextKind: WorkDraftKind) => { setViewState((prev) => ({ ...prev, draftKind: nextKind, - viewMode: "tabs", activeItemId: null, selectedItemId: null, })); @@ -758,9 +754,7 @@ export function useLaneWorkSessions(laneId: string | null) { visibleSessions, gridLayoutId, activeItemId: laneViewState.activeItemId, - viewMode: laneViewState.viewMode, draftKind: laneViewState.draftKind, - setViewMode, showDraftKind, setActiveItemId, closeTab, diff --git a/apps/desktop/src/renderer/components/run/QuickRunMenu.tsx b/apps/desktop/src/renderer/components/run/QuickRunMenu.tsx index 000646756..b86481ecf 100644 --- a/apps/desktop/src/renderer/components/run/QuickRunMenu.tsx +++ b/apps/desktop/src/renderer/components/run/QuickRunMenu.tsx @@ -77,6 +77,55 @@ function QuickRunItem({ ); } +const inlineItemStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + width: "100%", + padding: "8px 10px", + background: "transparent", + border: "none", + borderRadius: 8, + color: COLORS.textSecondary, + cursor: "pointer", + fontFamily: MONO_FONT, + fontSize: 12, + fontWeight: 600, + textAlign: "left", +}; + +/** Dimmed hint row (loading / empty states) for the inline Run list. */ +const inlineHintStyle: React.CSSProperties = { + padding: "6px 10px", + color: COLORS.textDim, + fontFamily: MONO_FONT, + fontSize: 11, +}; + +/** Plain (non-dropdown) button row for the inline Run list. */ +function QuickRunInlineItem({ + icon, + label, + onSelect, +}: { + icon: React.ReactNode; + label: string; + onSelect: () => void | Promise; +}) { + return ( + + ); +} + function isTrustError(err: unknown): boolean { return err instanceof Error && err.message.includes("ADE_TRUST_REQUIRED"); } @@ -289,3 +338,101 @@ export function QuickRunMenu({ ); } + +/** + * Inline (non-dropdown) version of the run actions — used by the chat-actions + * "Run" tab so the options are visible directly instead of behind a trigger. + */ +export function QuickRunInlineList({ laneId }: { laneId: string | null }) { + const navigate = useNavigate(); + const selectLane = useAppStore((s) => s.selectLane); + const [groups, setGroups] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + if (!laneId) return; + let cancelled = false; + setLoading(true); + window.ade.projectConfig + .get() + .then((snapshot) => { if (!cancelled) setGroups(snapshot.effective.processGroups ?? []); }) + .catch(() => { if (!cancelled) setGroups([]); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [laneId]); + + const syncLaneSelection = React.useCallback(() => { + if (!laneId) return false; + selectLane(laneId); + return true; + }, [laneId, selectLane]); + + return ( +
    +
    Lane runtime
    + } label="Open Run tab" onSelect={() => { if (syncLaneSelection()) navigate("/project"); }} /> + } label="Open shell in Work" onSelect={() => { if (laneId) { selectLane(laneId); navigate("/work"); } }} /> + } label="Start all commands" onSelect={async () => { if (!syncLaneSelection() || !laneId) return; await startAllWithTrust(laneId); }} /> + } label="Stop all commands" onSelect={async () => { syncLaneSelection(); if (!laneId) return; await window.ade.processes.stopAll({ laneId }); }} /> + } label="Restart all commands" onSelect={async () => { syncLaneSelection(); if (!laneId) return; await window.ade.processes.stopAll({ laneId }); await startAllWithTrust(laneId); }} /> + +
    +
    Process groups
    + {loading ? ( +
    Loading group actions...
    + ) : groups.length === 0 ? ( +
    No groups configured.
    + ) : ( + groups.map((group) => ( + +
    {group.name}
    + } + label={`Start ${group.name}`} + onSelect={async () => { + if (!syncLaneSelection() || !laneId) return; + const laneByProcessId = await buildLaneByProcessId(group.id, laneId); + if (!laneByProcessId) return; + try { + await window.ade.processes.startGroup({ groupId: group.id, laneByProcessId }); + } catch (err) { + if (isTrustError(err)) { + await window.ade.projectConfig.confirmTrust(); + await window.ade.processes.startGroup({ groupId: group.id, laneByProcessId }); + } else { throw err; } + } + }} + /> + } + label={`Stop ${group.name}`} + onSelect={async () => { + syncLaneSelection(); if (!laneId) return; + const laneByProcessId = await buildLaneByProcessId(group.id, laneId); + if (!laneByProcessId) return; + await window.ade.processes.stopGroup({ groupId: group.id, laneByProcessId }); + }} + /> + } + label={`Restart ${group.name}`} + onSelect={async () => { + syncLaneSelection(); if (!laneId) return; + const laneByProcessId = await buildLaneByProcessId(group.id, laneId); + if (!laneByProcessId) return; + try { + await window.ade.processes.restartGroup({ groupId: group.id, laneByProcessId }); + } catch (err) { + if (isTrustError(err)) { + await window.ade.projectConfig.confirmTrust(); + await window.ade.processes.restartGroup({ groupId: group.id, laneByProcessId }); + } else { throw err; } + } + }} + /> +
    + )) + )} +
    + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx index 9a6bd88e1..fcb274416 100644 --- a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx @@ -229,6 +229,11 @@ export function CliSessionWorkSurfaceHeader({ onInfoClick, onContextMenu, onStopRunningSession, + onToggleSessionsPane, + sessionsPaneCollapsed, + sessionsPaneCount, + onToggleToolsPane, + toolsPaneOpen, }: { session: TerminalSessionSummary; lanes: LaneSummary[]; @@ -237,6 +242,11 @@ export function CliSessionWorkSurfaceHeader({ onInfoClick?: SessionMouseHandler; onContextMenu?: SessionMouseHandler; onStopRunningSession?: (session: TerminalSessionSummary) => void; + onToggleSessionsPane?: () => void; + sessionsPaneCollapsed?: boolean; + sessionsPaneCount?: number; + onToggleToolsPane?: () => void; + toolsPaneOpen?: boolean; }) { const navigate = useNavigate(); const lane = lanes.find((entry) => entry.id === session.laneId) ?? null; @@ -269,6 +279,11 @@ export function CliSessionWorkSurfaceHeader({ showCacheBadge={showCache} cacheIdleSinceAt={session.chatIdleSinceAt} showGitToolbar + onToggleSessionsPane={onToggleSessionsPane} + sessionsPaneCollapsed={sessionsPaneCollapsed} + sessionsPaneCount={sessionsPaneCount} + onToggleToolsPane={onToggleToolsPane} + toolsPaneOpen={toolsPaneOpen} trailingActions={ <> diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index f5222574f..38fa1cde1 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -55,7 +55,6 @@ describe("SessionCard orchestration identity", () => { lane={lane} isSelected={false} onSelect={vi.fn()} - onInfoClick={vi.fn()} onContextMenu={vi.fn()} />, ); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 282df406b..a176d2659 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Info, WarningCircle } from "@phosphor-icons/react"; +import { GridFour, WarningCircle } from "@phosphor-icons/react"; import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import type { OrchestrationRole } from "../../../shared/types/orchestration"; import { sessionStatusDot, sanitizeTerminalInlineText } from "../../lib/terminalAttention"; @@ -9,11 +9,11 @@ import { preferredSessionLabel, } from "../../lib/sessions"; import { relativeTimeCompact } from "../../lib/format"; +import { GRID_SESSION_DND_MIME } from "../../lib/workGrid"; import { useSessionDelta } from "./useSessionDelta"; import { cn } from "../ui/cn"; import { MONO_FONT } from "../lanes/laneDesignTokens"; import { ToolLogo } from "./ToolLogos"; -import { SmartTooltip } from "../ui/SmartTooltip"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; @@ -123,18 +123,19 @@ export const SessionCard = React.memo(function SessionCard({ isSelected, isMultiSelected, onSelect, - onInfoClick, onContextMenu, compact = false, + gridBadge = null, }: { session: TerminalSessionSummary; lane: LaneSummary | null; isSelected: boolean; isMultiSelected?: boolean; onSelect: (id: string, event: React.MouseEvent) => void; - onInfoClick: (e: React.MouseEvent) => void; onContextMenu: (e: React.MouseEvent) => void; compact?: boolean; + /** Grid membership indicator: "active" = in the currently-viewed grid, "inactive" = in another grid, null = not gridded. */ + gridBadge?: "active" | "inactive" | null; }) { const dot = sessionStatusDot(session); const delta = useSessionDelta(session.id, true); @@ -166,29 +167,38 @@ export const SessionCard = React.memo(function SessionCard({ const orchestrationLabel = session.orchestrationRole ? orchestrationRoleA11yLabel(session.orchestrationRole, session.orchestrationTag ?? null) : null; + const isActiveGrid = gridBadge === "active"; + const gridLabel = isActiveGrid ? "In the active grid" : "In another grid"; return ( -
    +
    { + // Source for the Cursor-style work grid: drop onto a session / the work + // area to add this chat or CLI session to a grid. + event.dataTransfer.setData(GRID_SESSION_DND_MIME, session.id); + event.dataTransfer.effectAllowed = "copyMove"; + }} + >
    - - {/* Hover actions — bottom-right so they don’t compete with title row */} -
    - - - -
    ); }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index cf61d80f9..a843cc72d 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -21,6 +21,9 @@ type SessionContextMenuProps = { onCopySessionDeepLink?: (session: TerminalSessionSummary) => void; onTogglePinned?: (session: TerminalSessionSummary) => void; pinnedSessionIds?: string[]; + /** Session ids currently in any work grid (drives the "Remove from grid" item). */ + gridSessionIds?: string[]; + onRemoveFromGrid?: (session: TerminalSessionSummary) => void; }; export function SessionContextMenu({ @@ -36,6 +39,8 @@ export function SessionContextMenu({ onCopySessionDeepLink, onTogglePinned, pinnedSessionIds, + gridSessionIds, + onRemoveFromGrid, }: SessionContextMenuProps) { const [renaming, setRenaming] = useState(false); const [draft, setDraft] = useState(""); @@ -209,6 +214,18 @@ export function SessionContextMenu({ {(pinnedSessionIds ?? []).includes(session.id) ? "Unpin from front" : "Pin to front"} ) : null} + + {onRemoveFromGrid && (gridSessionIds ?? []).includes(session.id) ? ( + <> +
    + + + ) : null}
    ); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index 3386b8e1d..b8c889968 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -83,7 +83,6 @@ function renderPane(props: Partial> = {}) showingDraft={false} onShowDraftKind={vi.fn()} onSelectSession={vi.fn()} - onInfoClick={vi.fn()} onContextMenu={vi.fn()} sessionListOrganization="by-lane" setSessionListOrganization={vi.fn()} @@ -141,7 +140,6 @@ describe("SessionListPane", () => { showingDraft={false} onShowDraftKind={vi.fn()} onSelectSession={vi.fn()} - onInfoClick={vi.fn()} onContextMenu={vi.fn()} sessionListOrganization="by-lane" setSessionListOrganization={vi.fn()} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 648c9d6b8..186f609bd 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -1,12 +1,14 @@ import React, { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { CaretDown, CaretRight, Funnel, MagnifyingGlass, Plus, Square, Terminal, Trash, X } from "@phosphor-icons/react"; +import { AnimatePresence, motion } from "motion/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 } from "../../state/appStore"; +import type { WorkDraftKind, WorkGridSet, WorkSessionListOrganization } from "../../state/appStore"; +import { findGridSetForSession } from "../../lib/workGrid"; import { iconGlyph } from "../graph/graphHelpers"; import { SmartTooltip } from "../ui/SmartTooltip"; import { cn } from "../ui/cn"; @@ -16,6 +18,7 @@ import { canBulkDeleteSession, canBulkStopSession } from "../../lib/sessions"; import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; +const EMPTY_GRID_SETS: WorkGridSet[] = []; 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"; @@ -75,7 +78,7 @@ function StickyGroupHeader({ type="button" className={cn( "ade-lane-group-header sticky top-0 z-10 flex w-full items-center text-left transition-colors backdrop-blur-xl cursor-pointer select-none", - isLane ? "gap-1.5 rounded-lg px-2.5 py-1.5" : "gap-1.5 rounded-md px-2 py-1.5", + isLane ? "gap-1.5 rounded-lg px-3 py-2" : "gap-1.5 rounded-md px-2 py-1.5", laneTint.text ? "hover:brightness-[1.03]" : "hover:bg-white/[0.04]", )} style={{ @@ -93,7 +96,7 @@ function StickyGroupHeader({ data-section-id={sectionId} > {isLane ? ( -
    +
    {collapsed ? ( ) : ( @@ -101,28 +104,28 @@ function StickyGroupHeader({ )} {icon} {label} -
    - {showBranchCluster ? ( -
    - - - {branchText} - -
    - ) : null} - - {count} - -
    + {/* Branch sits immediately right of the lane name and expands to fill + whatever space is free, truncating only when it runs out. */} + {showBranchCluster ? ( +
    + + + {branchText} + +
    + ) : null} + + {count} +
    ) : ( <> @@ -144,16 +147,23 @@ function StickyGroupHeader({ )} - {!collapsed && count > 0 ? ( -
    - {children} -
    - ) : null} + {/* Children slide out/retract smoothly; the header stays put (no reflow jump). */} + + {!collapsed && count > 0 ? ( + +
    + {children} +
    +
    + ) : null} +
    ); } @@ -177,7 +187,6 @@ export const SessionListPane = React.memo(function SessionListPane({ onClearSelection, onBulkClose, onBulkDelete, - onInfoClick, onContextMenu, sessionListOrganization, setSessionListOrganization, @@ -186,6 +195,8 @@ export const SessionListPane = React.memo(function SessionListPane({ workCollapsedSectionIds, toggleWorkSectionCollapsed, sessionsGroupedByLane, + gridSets = EMPTY_GRID_SETS, + activeItemId = null, }: { lanes: LaneSummary[]; runningFiltered: TerminalSessionSummary[]; @@ -198,6 +209,8 @@ export const SessionListPane = React.memo(function SessionListPane({ setQ: (v: string) => void; selectedSessionId: string | null; selectedSessionIds?: Set; + gridSets?: WorkGridSet[]; + activeItemId?: string | null; draftKind: WorkDraftKind; showingDraft: boolean; onShowDraftKind: (kind: WorkDraftKind) => void; @@ -205,7 +218,6 @@ export const SessionListPane = React.memo(function SessionListPane({ onClearSelection?: () => void; onBulkClose?: () => void; onBulkDelete?: () => void; - onInfoClick: (session: TerminalSessionSummary, e: React.MouseEvent) => void; onContextMenu: (session: TerminalSessionSummary, e: React.MouseEvent) => void; sessionListOrganization: WorkSessionListOrganization; setSessionListOrganization: (v: WorkSessionListOrganization) => void; @@ -368,6 +380,15 @@ export const SessionListPane = React.memo(function SessionListPane({ // tab tour can anchor at a real session. We track whether we've already // emitted the anchor across the whole list (not per-section). let sessionItemAnchorEmitted = false; + // The "active" grid is the set containing the focused session; its members' + // badges are highlighted, members of other grids are greyed. + const activeGridSetId = findGridSetForSession(gridSets, activeItemId)?.id ?? null; + const gridBadgeFor = (sessionId: string): "active" | "inactive" | null => { + const set = findGridSetForSession(gridSets, sessionId); + if (!set) return null; + return set.id === activeGridSetId ? "active" : "inactive"; + }; + const renderCardCore = (session: TerminalSessionSummary, options?: { compact?: boolean }) => { const isFirst = !sessionItemAnchorEmitted; if (isFirst) sessionItemAnchorEmitted = true; @@ -379,12 +400,12 @@ export const SessionListPane = React.memo(function SessionListPane({ isSelected={selectedSessionId === session.id} isMultiSelected={selectedSessionIds?.has(session.id) ?? false} onSelect={(id, event) => onSelectSession(id, event, renderedSessionIds)} - onInfoClick={(e) => onInfoClick(session, e)} onContextMenu={(e) => { e.preventDefault(); onContextMenu(session, e); }} compact={options?.compact} + gridBadge={gridBadgeFor(session.id)} /> ); if (!isFirst) return card; @@ -589,25 +610,6 @@ export const SessionListPane = React.memo(function SessionListPane({ onChange={(e) => setQ(e.target.value)} />
    - - - +
    {/* Expandable filter panel */} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index 60888e81d..9a74e7cc9 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -91,7 +91,6 @@ const workMocks = vi.hoisted(() => { sessions: [], visibleSessions: [], tabGroups: [], - tabVisibleSessionIds: [], runningFiltered: [], awaitingInputFiltered: [], endedFiltered: [], @@ -100,9 +99,10 @@ const workMocks = vi.hoisted(() => { sessionsGroupedByLane: [], loading: false, gridLayoutId: "work-grid", + gridSets: [], + setGridSets: vi.fn(), activeItemId: null, selectedSessionId: null, - viewMode: "tabs", draftKind: "chat", draftLaneId: null, filterLaneId: "all", @@ -119,7 +119,6 @@ const workMocks = vi.hoisted(() => { closingPtyIds: new Set(), setSelectedSessionId: vi.fn(), setActiveItemId: vi.fn(), - setViewMode: vi.fn(), closeTab: vi.fn(), launchPtySession: vi.fn(), setDraftLaneId: vi.fn(), @@ -340,14 +339,12 @@ describe("TerminalsPage chat session activation", () => { type: "open-request", status: { profileProjectRoot: "/repo-two" }, }); - expect(workMocks.currentWork.setViewMode).not.toHaveBeenCalled(); expect(workMocks.currentWork.setWorkSidebarTab).not.toHaveBeenCalled(); browserEventListener.current?.({ type: "open-request", status: { profileProjectRoot: "/repo-one" }, }); - expect(workMocks.currentWork.setViewMode).toHaveBeenCalledWith("tabs"); expect(workMocks.currentWork.setWorkSidebarTab).toHaveBeenCalledWith("browser"); }); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 231f48f87..f415747ab 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; import { PaneTilingLayout, type PaneConfig, type PaneSplit } from "../ui/PaneTilingLayout"; import { useWorkSessions } from "./useWorkSessions"; import { SessionListPane } from "./SessionListPane"; @@ -10,6 +11,9 @@ import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/t import { buildDeeplink } from "../../../shared/deeplinks"; import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { canBulkDeleteSession, canBulkStopSession, formatToolTypeLabel, isChatToolType } from "../../lib/sessions"; +import { addSessionBesideTarget, removeSessionFromGrids } from "../../lib/workGrid"; +import { buildWorkSessionTilingTree } from "./workSessionTiling"; +import type { DropEdge } from "../ui/paneTreeOps"; import { sortLanesForTabs } from "../lanes/laneUtils"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { useAppStore, type WorkDraftKind } from "../../state/appStore"; @@ -115,7 +119,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const [selectionAnchorId, setSelectionAnchorId] = useState(null); const workContentPaneRef = useRef(null); const workSidebarPaneRef = useRef(null); - const [workHeaderMount, setWorkHeaderMount] = useState(null); const unifiedChromeRef = useRef(null); const sessionsPaneRoRef = useRef(null); @@ -549,12 +552,11 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { contextDisabledReason = null; } - const workSidebarVisible = active && work.workSidebarOpen && work.viewMode !== "grid"; - const { setViewMode, setWorkSidebarTab, showDraftKind } = work; + const workSidebarVisible = active && work.workSidebarOpen; + const { setWorkSidebarTab, showDraftKind } = work; useEffect(() => { if (!active) return; const openBrowserSidebar = () => { - setViewMode("tabs"); setWorkSidebarTab("browser"); }; window.addEventListener(ADE_OPEN_BUILT_IN_BROWSER_EVENT, openBrowserSidebar); @@ -578,7 +580,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { window.removeEventListener("ade:work:stop-orchestrator-chat", stopOrchestratorChat); unsubscribeBrowserEvents?.(); }; - }, [active, projectRoot, setViewMode, setWorkSidebarTab, showDraftKind]); + }, [active, projectRoot, setWorkSidebarTab, showDraftKind]); const toggleSessionsPane = useCallback(() => { work.setWorkFocusSessionsHidden(!work.workFocusSessionsHidden); @@ -589,6 +591,76 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { const closeWorkSidebar = useCallback(() => { work.setWorkSidebarOpen(false); }, [work]); + + // --- Cursor-style work grid: membership mutations --- + // A session card dropped onto an existing grid tile. PaneTilingLayout already + // spliced the dragged session into the tree at the hovered edge; here we only + // update the grid set's membership and focus the dropped session. + const handleAddSessionToGrid = useCallback((draggedId: string, targetId: string, _edge: DropEdge) => { + if (draggedId === targetId) return; + work.setGridSets((prev) => addSessionBesideTarget(prev, { + sessionId: draggedId, + targetSessionId: targetId, + projectRoot, + }).gridSets); + work.openSessionTab(draggedId); + work.setActiveItemId(draggedId); + }, [work, projectRoot]); + + // A session card dropped onto a single (non-grid) session — create a new grid + // from the pair, seeding the split tree to honor the drop edge. + const handleCreateGridFromSingle = useCallback((draggedId: string, targetId: string, edge: DropEdge) => { + if (draggedId === targetId) return; + const placeAfter = edge === "right" || edge === "bottom"; + const { gridSets: next, gridSetId } = addSessionBesideTarget(work.gridSets, { + sessionId: draggedId, + targetSessionId: targetId, + projectRoot, + placeAfterTarget: placeAfter, + }); + const set = next.find((entry) => entry.id === gridSetId); + if (set) { + const preset = edge === "left" || edge === "right" ? "columns" : "rows"; + const tree: PaneSplit = buildWorkSessionTilingTree(set.sessionIds, preset); + window.ade.tilingTree.set(set.layoutId, tree).catch(() => {}); + } + work.setGridSets(next); + work.openSessionTab(draggedId); + work.setActiveItemId(draggedId); + }, [work, projectRoot]); + + // Remove a session from any grid it belongs to (right-click / drag-out) and + // open it as a single session. + const handleRemoveSessionFromGrid = useCallback((sessionId: string) => { + work.setGridSets((prev) => removeSessionFromGrids(prev, sessionId)); + work.openSessionTab(sessionId); + work.setActiveItemId(sessionId); + }, [work]); + + const gridSessionIds = useMemo( + () => work.gridSets.flatMap((set) => set.sessionIds), + [work.gridSets], + ); + + // Keep grid membership in sync with live sessions: drop members that no longer + // exist (closed/deleted) and dissolve any set that falls below two tiles. + const setGridSets = work.setGridSets; + useEffect(() => { + if (work.loading || work.sessions.length === 0) return; + const liveIds = new Set(work.sessions.map((s) => s.id)); + setGridSets((prev) => { + let changed = false; + const next = prev + .map((set) => { + const filtered = set.sessionIds.filter((id) => liveIds.has(id)); + if (filtered.length !== set.sessionIds.length) changed = true; + return { ...set, sessionIds: filtered }; + }) + .filter((set) => set.sessionIds.length >= 2); + if (next.length !== prev.length) changed = true; + return changed ? next : prev; + }); + }, [work.sessions, work.loading, setGridSets]); const handleStopRunningSession = useCallback((session: TerminalSessionSummary) => { if (!session.ptyId) return; work.stopRuntime(session.ptyId, session.id).catch((err: unknown) => { @@ -654,25 +726,19 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { () => ( { - work.setViewMode("tabs"); - work.openSessionTab(sessionId); - work.setActiveItemId(sessionId); - }} onGoToLane={handleGoToLaneById} + gridSets={work.gridSets} + onAddSessionToGrid={handleAddSessionToGrid} + onCreateGridFromSingle={handleCreateGridFromSingle} + onRemoveSessionFromGrid={handleRemoveSessionFromGrid} /> ), [ sortedLanes, active, - work.gridLayoutId, + work.gridSets, + handleAddSessionToGrid, + handleCreateGridFromSingle, + handleRemoveSessionFromGrid, work.sessions, work.visibleSessions, - work.tabGroups, - work.tabVisibleSessionIds, work.activeItemId, - work.viewMode, work.draftKind, work.draftLaneId, draftContextTargetId, work.setDraftLaneId, work.showDraftKind, - work.setViewMode, work.setActiveItemId, work.closeTab, work.launchPtySession, - work.toggleWorkTabGroupCollapsed, work.closingPtyIds, work.filtered.length, - work.runningSessions.length, - work.loading, work.workFocusSessionsHidden, work.workSidebarOpen, toggleSessionsPane, - workHeaderMount, toggleWorkSidebar, handleOpenChatSession, handleContinueCliSession, @@ -731,8 +787,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { handleContextMenu, handleInfoClick, handleStopRunningSession, - work.reorderLaneSessions, - work.openSessionTab, handleGoToLaneById, ], ); @@ -747,33 +801,40 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { > {workViewArea}
    - {workSidebarVisible ? ( - <> -
    -
    - + {workSidebarVisible ? ( + <> +
    -
    - - ) : null} + + + + + ) : null} +
    ), [ @@ -814,6 +875,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { setQ={work.setQ} selectedSessionId={work.selectedSessionId} selectedSessionIds={selectedSessionIds} + gridSets={work.gridSets} + activeItemId={work.activeItemId} draftKind={work.draftKind} showingDraft={work.activeItemId == null} onShowDraftKind={work.showDraftKind} @@ -824,7 +887,6 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }} onBulkClose={handleBulkCloseSelected} onBulkDelete={handleBulkDeleteSelected} - onInfoClick={handleInfoClick} onContextMenu={handleContextMenu} sessionListOrganization={work.sessionListOrganization} setSessionListOrganization={work.setSessionListOrganization} @@ -879,7 +941,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) {
    ) : (
    -
    + {/* Legacy top tab/grid bar removed — each chat/CLI surface owns its own + header (far-left sessions toggle + far-right Tools toggle). */} work.togglePinnedSession(session.id)} pinnedSessionIds={work.pinnedSessionIds} + gridSessionIds={gridSessionIds} + onRemoveFromGrid={(session) => handleRemoveSessionFromGrid(session.id)} onRename={(session, newTitle) => { setSessionActionError(null); const renamePromise = isChatToolType(session.toolType) diff --git a/apps/desktop/src/renderer/components/terminals/WorkGridView.tsx b/apps/desktop/src/renderer/components/terminals/WorkGridView.tsx new file mode 100644 index 000000000..cd0e897b9 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/WorkGridView.tsx @@ -0,0 +1,165 @@ +import { useMemo, useRef, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import type { WorkGridSet } from "../../state/appStore"; +import { PaneTilingLayout, type PaneConfig } from "../ui/PaneTilingLayout"; +import { detectDropEdge, type DropEdge } from "../ui/paneTreeOps"; +import { buildWorkSessionTilingTree } from "./workSessionTiling"; +import { GRID_SESSION_DND_MIME } from "../../lib/workGrid"; +import { getLaneAccent } from "../lanes/laneColorPalette"; +import { primarySessionLabel } from "../../lib/sessions"; + +/** Half-surface overlay rectangle for a hovered drop edge (matches FloatingPane). */ +function edgeOverlayStyle(edge: DropEdge): CSSProperties | null { + switch (edge) { + case "top": return { top: 0, left: 0, right: 0, height: "50%" }; + case "bottom": return { bottom: 0, left: 0, right: 0, height: "50%" }; + case "left": return { top: 0, left: 0, bottom: 0, width: "50%" }; + case "right": return { top: 0, right: 0, bottom: 0, width: "50%" }; + case "center": return null; + } +} + +/** + * Wraps a single (non-grid) session surface and accepts a session card dropped + * from the sidebar — creating a brand-new grid from the pair. Shows a live edge + * preview (split left/right/top/bottom) while dragging over it. + */ +export function SingleSessionGridDropZone({ + targetSessionId, + onDropSession, + children, +}: { + targetSessionId: string; + onDropSession: (draggedSessionId: string, edge: DropEdge) => void; + children: ReactNode; +}) { + const ref = useRef(null); + const [edge, setEdge] = useState(null); + + const isExternal = (e: React.DragEvent) => e.dataTransfer.types.includes(GRID_SESSION_DND_MIME); + + return ( +
    { + if (!isExternal(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + const rect = ref.current?.getBoundingClientRect(); + if (!rect) return; + let next = detectDropEdge(rect, e.clientX, e.clientY); + if (next === "center") next = "right"; + setEdge(next); + }} + onDragLeave={(e) => { + if (ref.current && e.relatedTarget && ref.current.contains(e.relatedTarget as Node)) return; + setEdge(null); + }} + onDrop={(e) => { + if (!isExternal(e)) return; + e.preventDefault(); + const payload = e.dataTransfer.getData(GRID_SESSION_DND_MIME).trim(); + const dropEdge = edge && edge !== "center" ? edge : "right"; + setEdge(null); + if (payload && payload !== targetSessionId) onDropSession(payload, dropEdge); + }} + > + {children} + {edge && edge !== "center" ? ( +
    + ) : null} +
    + ); +} + +/** + * Cursor-style work grid: renders the sessions of one grid set in a resizable + * split layout (reusing PaneTilingLayout for drop-at-edge rearrange + free + * resize + persistence). Each tile renders the session's own full surface — + * the chat/CLI keeps its normal header (which doubles as the tile drag handle + * via FloatingPane embedded chrome), so nothing about a session changes when it + * joins a grid; it just shares the screen. + * + * Dragging a session card from the sidebar onto a tile splits at the hovered + * edge and fires `onAddSessionToGrid`. + */ +export function WorkGridView({ + gridSet, + sessions, + lanes, + activeItemId, + renderSession, + onAddSessionToGrid, + onRemoveFromGrid, + onFocusSession, + className, +}: { + gridSet: WorkGridSet; + sessions: TerminalSessionSummary[]; + lanes: LaneSummary[]; + activeItemId: string | null; + /** Renders the full session surface (chat or CLI) for a grid tile. */ + renderSession: (session: TerminalSessionSummary) => ReactNode; + /** A session card was dropped onto `targetSessionId` at `edge`. */ + onAddSessionToGrid: (draggedSessionId: string, targetSessionId: string, edge: DropEdge) => void; + /** A tile was dragged out of the grid (released outside any pane). */ + onRemoveFromGrid: (sessionId: string) => void; + onFocusSession: (sessionId: string) => void; + className?: string; +}) { + const members = useMemo( + () => + gridSet.sessionIds + .map((id) => sessions.find((s) => s.id === id) ?? null) + .filter((s): s is TerminalSessionSummary => s != null), + [gridSet.sessionIds, sessions], + ); + + // Key the fallback tree on the stable resolved-id signature, NOT on the + // `members`/`sessions` object identity. The session list refreshes every few + // seconds with fresh objects; without this the `tree` prop changed identity + // each poll and retriggered PaneTilingLayout's load effect — a visible flicker. + const liveMemberKey = members.map((m) => m.id).join("|"); + const fallbackTree = useMemo( + () => buildWorkSessionTilingTree(liveMemberKey ? liveMemberKey.split("|") : [], "auto"), + [liveMemberKey], + ); + + const panes = useMemo(() => { + const map: Record = {}; + for (const session of members) { + const lane = lanes.find((entry) => entry.id === session.laneId) ?? null; + const laneAccent = getLaneAccent(lane, 0); + map[session.id] = { + title: primarySessionLabel(session), + laneAccentColor: laneAccent, + // The session's own header is the tile header + drag handle. + hideHeaderWhenExpanded: true, + minimizable: false, + onPaneMouseDown: () => onFocusSession(session.id), + renderChildren: () => renderSession(session), + children: null, + }; + } + return map; + }, [members, lanes, renderSession, onFocusSession]); + + // Need at least two live members to be a grid; otherwise the parent dissolves + // the set and we render nothing here. + if (members.length < 2) return null; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index 12dd7ec67..d8d1d47e5 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -102,7 +102,7 @@ export function WorkStartSurface({ if (!lanes.length) { return ( -
    +
    {lanesLoading ? ( @@ -121,7 +121,7 @@ export function WorkStartSurface({ } return ( -
    +
    ({ WorkStartSurface: () =>
    , })); -vi.mock("../ui/PaneTilingLayout", () => ({ - PaneTilingLayout: ({ - layoutId, - tree, - panes, - }: { - layoutId: string; - tree: unknown; - panes: Record void }>; - }) => { - latestPaneTilingLayoutProps = { layoutId, tree, panes }; - return ( -
    - {Object.entries(panes).map(([paneId, pane]) => ( -
    - {pane.children} -
    - ))} -
    - ); - }, -})); - -let latestPaneTilingLayoutProps: { - layoutId: string; - tree: unknown; - panes: Record void }>; -} | null = null; const terminalPreviewMock = vi.fn(); const slashCommandsMock = vi.fn(); const modelsMock = vi.fn(); @@ -162,7 +127,6 @@ const resourceUsageMock = vi.fn(); const resolvePtyLaunch = async () => ({ sessionId: "test-session", ptyId: "test-pty", pid: null }); beforeEach(() => { - latestPaneTilingLayoutProps = null; chatPaneLifecycle.mounts.clear(); chatPaneLifecycle.unmounts.clear(); terminalPreviewMock.mockReset(); @@ -315,7 +279,6 @@ describe("WorkViewArea", () => { it("shows only Chat and CLI start modes on the empty Work surface", () => { render( { }]} sessions={[]} visibleSessions={[]} - tabGroups={[]} - tabVisibleSessionIds={[]} activeItemId={null} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -361,7 +319,6 @@ describe("WorkViewArea", () => { it("keeps the Chat start mode selected for orchestrator drafts", () => { render( { }]} sessions={[]} visibleSessions={[]} - tabGroups={[]} - tabVisibleSessionIds={[]} activeItemId={null} - viewMode="tabs" draftKind="chat-orchestrator" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={async () => ({ ptyId: "pty-1", sessionId: "sess-1", pid: 1234 })} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -407,7 +359,6 @@ describe("WorkViewArea", () => { render( { }]} sessions={[session]} visibleSessions={[session]} - tabGroups={[]} - tabVisibleSessionIds={[session.id]} activeItemId={null} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -448,270 +394,11 @@ describe("WorkViewArea", () => { expect(screen.queryByText("Session ended")).toBeNull(); }); - it("minimizes and expands through embedded floating-pane chrome", () => { - const session = makeSession(); - - function EmbeddedWorkPane() { - const [minimized, setMinimized] = useState(false); - return ( - setMinimized((current) => !current)} - hideHeaderWhenExpanded - > - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - /> - - ); - } - - const view = render(); - const local = within(view.container); - const pane = () => view.container.querySelector('[data-pane-id="work-pane"]') as HTMLElement; - - expect(local.getByTestId("work-start-surface")).toBeTruthy(); - fireEvent.click(local.getByLabelText("Minimize pane")); - - expect(pane().getAttribute("data-minimized")).toBe("true"); - fireEvent.click(local.getByLabelText("Expand pane")); - - expect(pane().getAttribute("data-minimized")).toBe("false"); - expect(local.getByTestId("work-start-surface")).toBeTruthy(); - expect(local.getByLabelText("Minimize pane")).toBeTruthy(); - }); - - it("closes an ended work tab from the tab strip", () => { - const session = makeSession(); - const onCloseItem = vi.fn(); - const onSelectItem = vi.fn(); - - const view = render( - {}} - onSelectItem={onSelectItem} - onCloseItem={onCloseItem} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - const tab = view.getByRole("tab", { name: "Existing session" }); - const closeButton = view.getByRole("button", { name: "Close Existing session" }); - expect(closeButton.closest('[role="tab"]')).toBeNull(); - fireEvent.mouseDown(closeButton); - fireEvent.click(closeButton); - - expect(onCloseItem).toHaveBeenCalledTimes(1); - expect(onCloseItem).toHaveBeenCalledWith("session-1"); - expect(onSelectItem).not.toHaveBeenCalled(); - - fireEvent.click(tab); - expect(onSelectItem).toHaveBeenCalledTimes(1); - }); - - it("keeps every running terminal tile mounted in grid mode", () => { - const first = makeRunningSession("session-1", "pty-1"); - const second = makeRunningSession("session-2", "pty-2"); - - render( - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - expect(screen.getAllByTestId("terminal-view")).toHaveLength(2); - expect(screen.getAllByTestId("terminal-view").map((node) => node.getAttribute("data-visible"))).toEqual(["true", "true"]); - }); - - it("cools background grid terminal streams under app pressure but keeps the focused terminal live", async () => { - resourceUsageMock.mockResolvedValue({ - sampledAt: "2026-04-06T12:00:00.000Z", - processCount: 8, - cpuPercent: 96, - mainCpuPercent: 12, - rendererCpuPercent: 96, - memoryMB: 7_000, - mainMemoryMB: 400, - rendererMemoryMB: 2_200, - activePtyCount: 24, - ptyProcessCount: 36, - ptyCpuPercent: 96, - ptyMemoryMB: 4_400, - freeMemoryMB: 600, - totalMemoryMB: 16_000, - }); - const sessions = Array.from({ length: 24 }, (_unused, index) => ( - makeRunningSession(`session-${index + 1}`, `pty-${index + 1}`) - )); - const lane = { - id: "lane-1", - name: "Lane 1", - laneType: "worktree" as const, - baseRef: "main", - branchRef: "lane-1", - worktreePath: "/tmp/lane-1", - 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-06T12:00:00.000Z", - }; - - const renderGrid = (activeItemId: string) => ( - session.id)} - activeItemId={activeItemId} - viewMode="grid" - draftKind="chat" - setViewMode={() => {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - /> - ); - - const view = render(renderGrid("session-1")); - - let cooledSessionId: string | null = null; - await waitFor(() => { - const terminals = within(view.container).getAllByTestId("terminal-view"); - expect(terminals).toHaveLength(sessions.length); - expect(terminals.find((node) => node.getAttribute("data-session-id") === "session-1")?.getAttribute("data-visible")).toBe("true"); - cooledSessionId = terminals.find((node) => ( - node.getAttribute("data-session-id") !== "session-1" - && node.getAttribute("data-visible") === "false" - ))?.getAttribute("data-session-id") ?? null; - expect(cooledSessionId).toBeTruthy(); - }); - - view.rerender(renderGrid(cooledSessionId!)); - - await waitFor(() => { - const focused = within(view.container).getAllByTestId("terminal-view") - .find((node) => node.getAttribute("data-session-id") === cooledSessionId); - expect(focused?.getAttribute("data-active")).toBe("true"); - expect(focused?.getAttribute("data-visible")).toBe("true"); - }); - }); - it("adds the CLI session header above agent PTY sessions", () => { const session = { ...makeRunningSession("session-1", "pty-1"), toolType: "claude" as const }; const view = render( { }]} sessions={[session]} visibleSessions={[session]} - tabGroups={[]} - tabVisibleSessionIds={[session.id]} activeItemId={session.id} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -801,7 +483,6 @@ describe("WorkViewArea", () => { const view = render( { }]} sessions={[session]} visibleSessions={[session]} - tabGroups={[]} - tabVisibleSessionIds={[session.id]} activeItemId={session.id} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -882,22 +558,16 @@ describe("WorkViewArea", () => { const view = render( {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -905,7 +575,9 @@ describe("WorkViewArea", () => { expect(await local.findByLabelText("Continue Claude Code session")).toBeTruthy(); expect(local.getAllByText("Existing session").length).toBeGreaterThan(0); - expect(local.getByText("Hidden session")).toBeTruthy(); + // Only the active session renders in the single-session work view; the + // hidden session is never mounted, so its preview/slash hydration never runs. + expect(local.queryByText("Hidden session")).toBeNull(); expect(terminalPreviewMock).toHaveBeenCalledTimes(1); expect(terminalPreviewMock).toHaveBeenCalledWith({ terminalId: "session-active", maxBytes: 160_000 }); expect(slashCommandsMock).toHaveBeenCalledTimes(1); @@ -984,22 +656,16 @@ describe("WorkViewArea", () => { const view = render( {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1073,22 +739,16 @@ describe("WorkViewArea", () => { const view = render( {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1126,22 +786,16 @@ describe("WorkViewArea", () => { const view = render( {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1201,22 +855,16 @@ describe("WorkViewArea", () => { const view = render( {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1245,7 +893,6 @@ describe("WorkViewArea", () => { const view = render( { }]} sessions={[session]} visibleSessions={[session]} - tabGroups={[]} - tabVisibleSessionIds={[session.id]} activeItemId={session.id} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} onContinueCliSession={onContinue} onResumeCliSession={onResume} @@ -1310,22 +952,16 @@ describe("WorkViewArea", () => { }; const view = render( {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1354,22 +990,16 @@ describe("WorkViewArea", () => { render( {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1384,238 +1014,6 @@ describe("WorkViewArea", () => { expect(await screen.findByText("/status")).toBeTruthy(); }); - it("keeps the grid tiling tree stable when refreshed session objects keep the same ids", () => { - const first = makeRunningSession("session-1", "pty-1"); - const second = makeRunningSession("session-2", "pty-2"); - - const view = render( - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - const initialTree = latestPaneTilingLayoutProps?.tree; - - const refreshedFirst = { ...first, lastOutputPreview: "new output" }; - const refreshedSecond = { ...second, summary: "updated summary" }; - view.rerender( - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - expect(latestPaneTilingLayoutProps?.tree).toBe(initialTree); - }); - - it("keeps chat tiles mounted across metadata-only grid refreshes", () => { - vi.mocked(isChatToolType).mockImplementation((toolType) => toolType === "codex-chat"); - const first = makeChatSession("chat-1"); - const second = makeChatSession("chat-2"); - - const view = render( - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - const refreshedFirst = { ...first, lastOutputPreview: "new output" }; - const refreshedSecond = { ...second, summary: "updated summary" }; - view.rerender( - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - expect(chatPaneLifecycle.mounts.get("chat-1")).toBe(1); - expect(chatPaneLifecycle.mounts.get("chat-2")).toBe(1); - expect(chatPaneLifecycle.unmounts.get("chat-1")).toBeUndefined(); - expect(chatPaneLifecycle.unmounts.get("chat-2")).toBeUndefined(); - }); - - it("marks every chat tile visible in grid mode while only the selected tile is active", () => { - vi.mocked(isChatToolType).mockImplementation((toolType) => toolType === "codex-chat"); - const first = makeChatSession("chat-1"); - const second = makeChatSession("chat-2"); - - const view = render( - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - const tiles = within(view.container).getAllByTestId("agent-chat-pane"); - const firstTile = tiles.find((el) => el.getAttribute("data-session-id") === "chat-1"); - const secondTile = tiles.find((el) => el.getAttribute("data-session-id") === "chat-2"); - expect(firstTile?.getAttribute("data-tile-active")).toBe("true"); - expect(firstTile?.getAttribute("data-tile-visible")).toBe("true"); - expect(secondTile?.getAttribute("data-tile-active")).toBe("false"); - expect(secondTile?.getAttribute("data-tile-visible")).toBe("true"); - }); - it("keeps chat panes mounted but inactive while the Work page is parked", () => { vi.mocked(isChatToolType).mockImplementation((toolType) => toolType === "codex-chat"); const session = makeChatSession("chat-1"); @@ -1623,7 +1021,6 @@ describe("WorkViewArea", () => { const view = render( { }]} sessions={[session]} visibleSessions={[session]} - tabGroups={[]} - tabVisibleSessionIds={[session.id]} activeItemId={session.id} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1674,7 +1066,6 @@ describe("WorkViewArea", () => { const view = render( { }]} sessions={[first, second]} visibleSessions={[first, second]} - tabGroups={[]} - tabVisibleSessionIds={[first.id, second.id]} activeItemId={first.id} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1721,7 +1107,6 @@ describe("WorkViewArea", () => { view.rerender( { }]} sessions={[first, second]} visibleSessions={[first, second]} - tabGroups={[]} - tabVisibleSessionIds={[first.id, second.id]} activeItemId={second.id} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1769,69 +1149,12 @@ describe("WorkViewArea", () => { expect(chatPaneLifecycle.unmounts.get("chat-2")).toBeUndefined(); }); - it("keeps active chat mounted when its tab group is collapsed", () => { - vi.mocked(isChatToolType).mockImplementation((toolType) => toolType === "codex-chat"); - const session = makeChatSession("chat-1"); - - const view = render( - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - expect(within(view.container).getByTestId("agent-chat-pane")).toBeTruthy(); - expect(chatPaneLifecycle.mounts.get("chat-1")).toBe(1); - expect(chatPaneLifecycle.unmounts.get("chat-1")).toBeUndefined(); - }); - it("parks hidden terminal tiles in tabs mode while switching active tab", () => { const first = makeRunningSession("session-1", "pty-1"); const second = makeRunningSession("session-2", "pty-2"); const view = render( { }]} sessions={[first, second]} visibleSessions={[first, second]} - tabGroups={[]} - tabVisibleSessionIds={[first.id, second.id]} activeItemId={first.id} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1874,7 +1192,6 @@ describe("WorkViewArea", () => { view.rerender( { }]} sessions={[first, second]} visibleSessions={[first, second]} - tabGroups={[]} - tabVisibleSessionIds={[first.id, second.id]} activeItemId={second.id} - viewMode="tabs" draftKind="chat" - setViewMode={() => {}} onSelectItem={() => {}} onCloseItem={() => {}} onOpenChatSession={() => {}} onLaunchPtySession={resolvePtyLaunch} onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} closingPtyIds={new Set()} />, ); @@ -1916,97 +1228,4 @@ describe("WorkViewArea", () => { expect(terminal.getAttribute("data-active")).toBe("true"); }); - it("preserves unusual session ids when building the grid tiling tree", () => { - const session = makeRunningSession("session\u0000with-delimiter", "pty-1"); - - render( - {}} - onSelectItem={() => {}} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - expect(collectLeafIds(latestPaneTilingLayoutProps?.tree as Parameters[0])).toEqual([session.id]); - }); - - it("selects a tiled session when its body is clicked in grid mode", () => { - const first = makeRunningSession("session-1", "pty-1"); - const second = makeRunningSession("session-2", "pty-2"); - const onSelectItem = vi.fn(); - - const view = render( - {}} - onSelectItem={onSelectItem} - onCloseItem={() => {}} - onOpenChatSession={() => {}} - onLaunchPtySession={resolvePtyLaunch} - onShowDraftKind={() => {}} - onToggleTabGroupCollapsed={() => {}} - closingPtyIds={new Set()} - />, - ); - - expect(latestPaneTilingLayoutProps?.layoutId).toBe("work:grid:test"); - fireEvent.mouseDown(within(view.container).getByTestId("pane-tiling-layout-pane:session-2")); - expect(onSelectItem).toHaveBeenCalledWith("session-2"); - }); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index b194df093..1f0473ff6 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1,30 +1,15 @@ import { useMemo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; -import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { ArrowClockwise, - CaretDown, - CaretRight, Chats, - Check, Code, - Columns, - DotsSixVertical, - Funnel, - GitBranch, - GridFour, - List, - Plus, PaperPlaneTilt, - Rows, - SidebarSimple, SpinnerGap, - X, } from "@phosphor-icons/react"; import type { AgentChatSession, AgentChatSlashCommand, - AppResourceUsageSnapshot, ChatTerminalPreviewResult, LaneLinearIssue, LaneSummary, @@ -33,42 +18,25 @@ import type { TerminalSnapshotCell, TerminalSnapshotRow, } from "../../../shared/types"; -import { useAppStore, type WorkDraftKind, type WorkViewMode } from "../../state/appStore"; +import { useAppStore, type WorkDraftKind, type WorkGridSet } from "../../state/appStore"; +import { findGridSetForSession } from "../../lib/workGrid"; +import type { DropEdge } from "../ui/paneTreeOps"; +import { WorkGridView, SingleSessionGridDropZone } from "./WorkGridView"; + +const EMPTY_GRID_SETS: WorkGridSet[] = []; import { TerminalView } from "./TerminalView"; import { ToolLogo } from "./ToolLogos"; -import { SessionLaneHeaderLabel } from "./LaneChip"; import { AgentChatPane, type AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { ChatCommandMenu, handleCommandMenuKeyDown, type ChatCommandMenuHandle, type ChatCommandMenuItem } from "../chat/ChatCommandMenu"; import { ChatComposerShell } from "../chat/ChatComposerShell"; import { WorkStartSurface } from "./WorkStartSurface"; -import { CliSessionWorkSurfaceHeader, GridTileSessionHeaderActions } from "./CliSessionWorkSurfaceHeader"; +import { CliSessionWorkSurfaceHeader } from "./CliSessionWorkSurfaceHeader"; import { isChatToolType, primarySessionLabel, stripTerminalLabelControls, truncateSessionLabel, formatToolTypeLabel } from "../../lib/sessions"; -import { sessionNeedsChatTabHighlight, sessionStatusDot } from "../../lib/terminalAttention"; -import type { WorkTabGroup } from "./useWorkSessions"; import { SmartTooltip } from "../ui/SmartTooltip"; -import { useFloatingPaneEmbeddedChrome, type FloatingPaneEmbeddedChrome } from "../ui/FloatingPane"; -import { PaneTilingLayout, type PaneConfig } from "../ui/PaneTilingLayout"; import { cn } from "../ui/cn"; import { launchProfileForTerminalSession, type WorkPtyLaunchArgs, type WorkPtyLaunchResult } from "./cliLaunch"; -import { buildWorkSessionTilingTree, type TilingPreset } from "./workSessionTiling"; -import { laneSurfaceTint } from "../lanes/laneDesignTokens"; import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; -import { - appResourcePressureLevel, - clampPressureLevel, - getAppResourceUsageCoalesced, - pressureLevelForThresholds, - type ResourcePressureLevel, -} from "../../lib/resourcePressure"; - -function isSessionAwaitingInput(session: TerminalSessionSummary): boolean { - return sessionNeedsChatTabHighlight({ - runtimeState: session.runtimeState, - toolType: session.toolType, - pendingInputItemId: session.pendingInputItemId, - }); -} function isRunningPtySession( session: TerminalSessionSummary | null | undefined, @@ -90,178 +58,6 @@ function isAgentCliSession(session: TerminalSessionSummary): boolean { ); } -type GridTerminalPressureLevel = ResourcePressureLevel; - -type GridTerminalRefreshPolicy = { - level: GridTerminalPressureLevel; - bucketCount: number; - pulseMs: number; -}; - -const GRID_TERMINAL_PRESSURE_SAMPLE_MS = 1_000; -const GRID_TERMINAL_RESOURCE_SAMPLE_MS = 2_000; -const NORMAL_GRID_TERMINAL_REFRESH_POLICY: GridTerminalRefreshPolicy = { - level: 0, - bucketCount: 1, - pulseMs: 0, -}; - -function readRendererHeapRatio(): number | null { - const perf = performance as Performance & { - memory?: { - usedJSHeapSize?: number; - totalJSHeapSize?: number; - jsHeapSizeLimit?: number; - }; - }; - const memory = perf.memory; - const used = memory?.usedJSHeapSize; - const limit = memory?.jsHeapSizeLimit; - if ( - typeof used !== "number" - || !Number.isFinite(used) - || typeof limit !== "number" - || !Number.isFinite(limit) - || limit <= 0 - ) { - return null; - } - return used / limit; -} - -function pressureLevelForSignals(args: { - driftMs: number; - rendererHeapRatio: number | null; - usage: AppResourceUsageSnapshot | null; -}): GridTerminalPressureLevel { - const driftLevel = pressureLevelForThresholds(args.driftMs, [80, 180, 350, 700]); - const heapLevel = pressureLevelForThresholds(args.rendererHeapRatio, [0.55, 0.68, 0.78, 0.88]); - const resourceLevel = appResourcePressureLevel(args.usage); - return clampPressureLevel(Math.max(driftLevel, heapLevel, resourceLevel)); -} - -function stabilizePressureLevel( - previous: GridTerminalPressureLevel, - sampled: GridTerminalPressureLevel, -): GridTerminalPressureLevel { - if (sampled >= previous) return sampled; - return clampPressureLevel(Math.max(sampled, previous - 1)); -} - -function gridTerminalRefreshPolicyForPressure(level: GridTerminalPressureLevel): GridTerminalRefreshPolicy { - switch (level) { - case 1: - return { level, bucketCount: 2, pulseMs: 650 }; - case 2: - return { level, bucketCount: 4, pulseMs: 900 }; - case 3: - return { level, bucketCount: 8, pulseMs: 1_200 }; - case 4: - return { level, bucketCount: 16, pulseMs: 1_600 }; - default: - return NORMAL_GRID_TERMINAL_REFRESH_POLICY; - } -} - -function stableBucketForSession(sessionId: string, bucketCount: number): number { - if (bucketCount <= 1) return 0; - let hash = 2166136261; - for (let index = 0; index < sessionId.length; index += 1) { - hash ^= sessionId.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0) % bucketCount; -} - -function shouldStreamGridTerminal(args: { - sessionId: string; - isActive: boolean; - policy: GridTerminalRefreshPolicy; - pulse: number; -}): boolean { - if (args.isActive || args.policy.level === 0) return true; - const bucket = stableBucketForSession(args.sessionId, args.policy.bucketCount); - return bucket === (args.pulse % args.policy.bucketCount); -} - -function useAdaptiveGridTerminalRefresh(enabled: boolean): { policy: GridTerminalRefreshPolicy; pulse: number } { - const [policy, setPolicy] = useState(NORMAL_GRID_TERMINAL_REFRESH_POLICY); - const [pulse, setPulse] = useState(0); - const latestUsageRef = useRef(null); - - useEffect(() => { - if (!enabled) { - latestUsageRef.current = null; - setPolicy(NORMAL_GRID_TERMINAL_REFRESH_POLICY); - setPulse(0); - return; - } - - let disposed = false; - let lastTick = performance.now(); - let lastResourceSampleAt = 0; - - const updatePolicy = (driftMs: number, usage: AppResourceUsageSnapshot | null) => { - const sampled = pressureLevelForSignals({ - driftMs, - rendererHeapRatio: readRendererHeapRatio(), - usage, - }); - setPolicy((previous) => { - const level = stabilizePressureLevel(previous.level, sampled); - return level === previous.level ? previous : gridTerminalRefreshPolicyForPressure(level); - }); - }; - - const sample = () => { - const now = performance.now(); - if (document.visibilityState !== "visible") { - lastTick = now; - return; - } - const driftMs = Math.max(0, now - lastTick - GRID_TERMINAL_PRESSURE_SAMPLE_MS); - lastTick = now; - updatePolicy(driftMs, latestUsageRef.current); - - if (lastResourceSampleAt > 0 && now - lastResourceSampleAt < GRID_TERMINAL_RESOURCE_SAMPLE_MS) return; - lastResourceSampleAt = now; - getAppResourceUsageCoalesced() - .then((usage) => { - if (disposed) return; - latestUsageRef.current = usage; - updatePolicy(0, usage); - }) - .catch(() => {}); - }; - - sample(); - const interval = window.setInterval(sample, GRID_TERMINAL_PRESSURE_SAMPLE_MS); - const sampleWhenVisible = () => { - if (document.visibilityState === "visible") sample(); - }; - document.addEventListener("visibilitychange", sampleWhenVisible); - return () => { - disposed = true; - window.clearInterval(interval); - document.removeEventListener("visibilitychange", sampleWhenVisible); - }; - }, [enabled]); - - useEffect(() => { - if (!enabled || policy.level === 0) { - setPulse(0); - return; - } - const interval = window.setInterval(() => { - if (document.visibilityState !== "visible") return; - setPulse((current) => current + 1); - }, policy.pulseMs); - return () => window.clearInterval(interval); - }, [enabled, policy.level, policy.pulseMs]); - - return { policy, pulse }; -} - function stoppedBySignal(exitCode: number | null | undefined): boolean { return exitCode === 130 || exitCode === 143; } @@ -780,6 +576,11 @@ function SessionSurface({ onOpenChatSession, onContinueCliSession, onResumeCliSession, + onToggleSessionsPane, + sessionsPaneCollapsed, + sessionsPaneCount, + onToggleToolsPane, + toolsPaneOpen, }: { session: TerminalSessionSummary; lanes: LaneSummary[]; @@ -795,6 +596,13 @@ function SessionSurface({ onOpenChatSession: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; onContinueCliSession?: (session: TerminalSessionSummary, text: string) => Promise | void; onResumeCliSession?: (session: TerminalSessionSummary) => Promise | void; + /** Far-left session-list expander (per-surface header now owns it). */ + onToggleSessionsPane?: () => void; + sessionsPaneCollapsed?: boolean; + sessionsPaneCount?: number; + /** Far-right Tools-pane toggle (per-surface header now owns it). */ + onToggleToolsPane?: () => void; + toolsPaneOpen?: boolean; }) { const isChat = isChatToolType(session.toolType); const surfaceActive = pageActive && isActive; @@ -812,6 +620,11 @@ function SessionSurface({ isTileActive={surfaceActive} isTileVisible={surfaceVisible} shouldAutofocusComposer={surfaceActive && shouldAutofocus} + onToggleSessionsPane={onToggleSessionsPane} + sessionsPaneCollapsed={sessionsPaneCollapsed} + sessionsPaneCount={sessionsPaneCount} + onToggleToolsPane={onToggleToolsPane} + toolsPaneOpen={toolsPaneOpen} /> ); } @@ -827,6 +640,11 @@ function SessionSurface({ onInfoClick={onInfoClick} onContextMenu={onContextMenu} onStopRunningSession={onStopRunningSession} + onToggleSessionsPane={onToggleSessionsPane} + sessionsPaneCollapsed={sessionsPaneCollapsed} + sessionsPaneCount={sessionsPaneCount} + onToggleToolsPane={onToggleToolsPane} + toolsPaneOpen={toolsPaneOpen} /> ) : null} void; - listCount: number; - runningCount: number; - listLoading: boolean; -}; - -function SessionsPaneToggle({ - collapsed, - onToggle, - listCount, - runningCount, - listLoading, -}: SessionsPaneToggleProps) { - let countHint: string; - if (listLoading) { - countHint = "Loading session list…"; - } else if (listCount > 0) { - countHint = `${listCount} in list${runningCount > 0 ? `, ${runningCount} running` : ""}`; - } else { - countHint = "Session list is empty."; - } - const label = collapsed ? "Show sessions" : "Hide sidebar"; - const description = collapsed - ? `Expand the sessions sidebar. ${countHint}` - : `Collapse the sessions sidebar. ${countHint}`; - return ( - - - - ); -} - function ModeSwitcherPills({ draftKind, onShowDraftKind, @@ -1033,434 +798,68 @@ function ModeSwitcherPills({ ); } -function WorkPaneEmbeddedChromeLeading({ chrome }: { chrome: FloatingPaneEmbeddedChrome | null }) { - if (!chrome?.minimizable) return null; - const { onMinimizeToggle, minimized, dragHandleProps } = chrome; - return ( -
    - {dragHandleProps?.draggable ? ( - - ) : null} - -
    - ); -} - -/** - * Glyph for the workspace-level "Tools" pane: a sidebar panel docked to the - * right with a stack of mixed content lines inside, plus an active-state - * indicator bar. Communicates "a side panel that holds many tools" at - * a glance — distinct from the chat-header Workbench chip (per-chat tools). - */ -function ToolsPaneGlyph({ open }: { open: boolean }) { - return ( -