From 404d08d24be8b7a6badf85c85c540c7a184ce455 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:02:39 +0200 Subject: [PATCH 01/17] Report failed terminal launches and remove early exits (#6792) * fix(server): report a terminal whose command cannot start instead of leaving a dead tab Opening a terminal profile whose command is not installed left a tab that could never be closed. Two problems sat on the same path. A terminal that dies during creation was resurrected by its own create. The worker sends terminalCreated, the createTerminal response and terminalExit in the same tick, so the parent drains all three before the awaiting create continuation resumes; that continuation called registerRecord a second time and put back a terminal the exit had just removed. Removal is owned solely by the terminalExit event and that event never comes twice, so the record outlived every close attempt: getTerminals kept listing it, capture returned nothing, kill was a silent no-op that still reported success, and only a daemon restart cleared it. The parent now tracks the ids with a create in flight and keeps the finished record when an exit removes one of them, so the create continuation returns the exited session instead of registering a live one. Removal still happens exactly once, in the terminalExit path. With the stuck tab gone, a missing command produced nothing at all: no tab and no message. node-pty spawns directly through execve on POSIX, with no shell, so a command that does not exist fails inside the spawn helper and surfaces as exit code 1 with no output -- exactly what `false` produces, so the exit carries no usable signal. createTerminal now resolves the command before spawning and fails the create when it cannot be found, so the daemon answers create_terminal with a null terminal and ": command not found", which the app already renders. A command that starts and then exits non-zero still opens its tab. That resolution has to match what execve will do in the child, or a working profile is refused: it uses the terminal's own environment, built first so the check sees the per-cwd registered PATH and the entries buildTerminalEnvironment prepends, and it resolves a command containing a separator against the terminal's cwd rather than through PATH. Windows keeps resolving inside resolveTerminalSpawnCommand, which has its own shim handling and fallback. * fix(terminal): check exact executable names and verify launch output --------- Co-authored-by: KiraPC Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../executable-resolution.ts | 24 +++ .../terminal-workspace-sdk.e2e.test.ts | 25 +++ packages/server/src/terminal/terminal.test.ts | 188 +++++++++++++++++- packages/server/src/terminal/terminal.ts | 105 +++++++++- .../terminal/worker-terminal-manager.test.ts | 136 +++++++++++++ .../src/terminal/worker-terminal-manager.ts | 30 +-- 6 files changed, 485 insertions(+), 23 deletions(-) diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index 59c218511b..97bb8af3a3 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -1,5 +1,6 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; +import { delimiter, resolve } from "node:path"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; import { windowsExecutableResolution } from "./windows.js"; @@ -120,6 +121,29 @@ export function executableExists( return exists(executablePath) ? executablePath : null; } +// PATH lookup that never runs the candidate, unlike findExecutable which probes +// each one with `--version`. A terminal spawn path cannot afford that probe: it +// would execute the profile command once before the terminal even opens. +// `path` overrides the PATH to search, so a caller can resolve against the +// environment its child process will actually get rather than the daemon's own. +export async function findExecutableWithoutProbing( + name: string, + options: { path?: string; cwd: string }, +): Promise { + if (!name) { + return null; + } + const path = + options.path === undefined + ? undefined + : options.path + .split(delimiter) + .map((entry) => resolve(options.cwd, entry)) + .join(delimiter); + const candidates = await enumerateCandidatesViaLibrary(name, { PATH: path }); + return candidates[0] ?? null; +} + export interface ExecutableResolutionOptions { probeTimeoutMs?: number; env?: ProcessEnvRecord; diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 19ccdf45e8..80c9409cef 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -244,3 +244,28 @@ async function observeScriptLifecycles( await subscription.ready; return lifecycles; } + +test.runIf(process.platform !== "win32")( + "a missing command fails creation without leaving a terminal listed", + async () => { + const workspaceId = await createWorkspace("Missing command"); + await expect( + sdk.terminals.create({ + workspaceId, + name: "Missing command", + command: "paseo-test-absent-terminal-command", + }), + ).rejects.toThrow("paseo-test-absent-terminal-command: command not found"); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); + const control = await sdk.terminals.create({ + workspaceId, + command: process.execPath, + args: ["-e", "process.stdin.resume()"], + }); + expect((await sdk.terminals.list({ workspaceId })).entries.map((entry) => entry.id)).toEqual([ + control.id, + ]); + await control.kill(); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); + }, +); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index f83b6b9ba3..751fb1b33a 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { buildTerminalEnvironment, + assertTerminalCommandCanStart, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, @@ -26,7 +27,7 @@ import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; import xterm from "@xterm/headless"; import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; import { stripVTControlCharacters } from "node:util"; @@ -637,6 +638,151 @@ describe("createTerminal", () => { expect(exitInfo.lastOutputLines.length).toBeGreaterThan(0); expect(exitInfo.lastOutputLines[exitInfo.lastOutputLines.length - 1]).toBe("line-2999"); }); + + it.runIf(isPlatform("linux", "darwin"))( + "rejects a profile command that is not installed instead of opening a terminal", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Codex", + command: "paseo-qa-absent-codex", + args: [], + }), + ).rejects.toThrow("paseo-qa-absent-codex: command not found"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "opens a command found only on the PATH carried by the env option", + async () => { + // execve resolves the command against the child's PATH, which carries the + // per-cwd registered environment. A version-manager shim lives there and + // not necessarily on the daemon's own PATH. + const binDir = mkdtempSync(join(tmpdir(), "terminal-envpath-bin-")); + temporaryDirs.push(binDir); + const toolPath = join(binDir, "paseo-qa-envpath-tool"); + writeFileSync(toolPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(toolPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Shim", + command: "paseo-qa-envpath-tool", + args: [], + env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` }, + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "reports command not found when the command is on neither PATH", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Codex", + command: "paseo-qa-absent-codex", + args: [], + env: { PATH: `/paseo-qa-empty${delimiter}${process.env.PATH ?? ""}` }, + }), + ).rejects.toThrow("paseo-qa-absent-codex: command not found"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))( + "opens a relative command path resolved against the terminal cwd", + async () => { + // execve resolves a path-ish command against the child's cwd, never the + // daemon's own working directory. + const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-cmd-")); + temporaryDirs.push(projectDir); + mkdirSync(join(projectDir, "scripts")); + const scriptPath = join(projectDir, "scripts", "dev.sh"); + writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(projectDir), + name: "Dev script", + command: "./scripts/dev.sh", + args: [], + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }, + ); + + it.runIf(isPlatform("linux", "darwin"))("opens an absolute command path", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "terminal-absolute-cmd-")); + temporaryDirs.push(projectDir); + const scriptPath = join(projectDir, "run.sh"); + writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Absolute", + command: scriptPath, + args: [], + }), + ); + + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + }); + + it("leaves the command check to the terminal itself on Windows", async () => { + // Windows resolves inside resolveTerminalSpawnCommand, which has its own + // shim handling and its own fallback for an unresolved command. + await expect( + assertTerminalCommandCanStart({ + command: "paseo-qa-absent-codex", + cwd: realpathSync(tmpdir()), + env: { PATH: "/paseo-qa-empty" }, + platform: "win32", + }), + ).resolves.toBeUndefined(); + }); + + it.runIf(isPlatform("linux", "darwin"))( + "still opens a terminal for an installed command that exits non-zero", + async () => { + // A profile running a failing test suite must keep opening a tab with its + // output. Only a command that cannot start at all is reported as an error, + // and "exited non-zero" does not distinguish the two: a missing binary and + // `false` both surface as exit code 1 from node-pty. + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + name: "Failing suite", + command: "false", + args: [], + }), + ); + + const exitInfo = await new Promise<{ exitCode: number | null }>((resolve) => { + session.onExit((info) => resolve({ exitCode: info.exitCode })); + }); + + expect(exitInfo).toEqual({ exitCode: 1 }); + }, + ); }); describe.skipIf(isPlatform("win32"))("send input", () => { @@ -1388,3 +1534,43 @@ describe.runIf(isPlatform("win32"))(".cmd shim argv round-trip on Windows", () = expect(await roundTrip("first\nsecond")).toEqual(["first second"]); }); }); + +it.runIf(isPlatform("linux", "darwin"))( + "opens a profile command from a PATH entry relative to the terminal cwd", + async () => { + const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-path-")); + mkdirSync(join(projectDir, "bin")); + const script = join(projectDir, "bin", "paseo-relative-path-tool"); + writeFileSync(script, "#!/bin/sh\nprintf 'paseo-launch-ok\\n'\nexec /bin/sleep 30\n"); + chmodSync(script, 0o755); + let session: TerminalSession | undefined; + try { + session = await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(projectDir), + command: "paseo-relative-path-tool", + env: { PATH: "bin" }, + }); + await waitForLines(session, ["paseo-launch-ok"]); + expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(session.cwd).toBe(realpathSync(projectDir)); + expect(session.getExitInfo()).toBeNull(); + } finally { + await session?.killAndWait({ gracefulTimeoutMs: 1000, forceTimeoutMs: 500 }); + rmSync(projectDir, { recursive: true, force: true }); + } + }, +); + +it.runIf(isPlatform("linux", "darwin"))( + "checks the exact command rather than accepting a trimmed executable name", + async () => { + await expect( + createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + command: " node ", + }), + ).rejects.toThrow(" node : command not found"); + }, +); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 1e708062b8..24f243b6ea 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,14 +1,25 @@ import * as pty from "node-pty"; import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdirSync, + readFileSync, + statSync, +} from "node:fs"; import { tmpdir, userInfo } from "node:os"; import { basename, delimiter, dirname, extname, join, resolve as resolvePath } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { writePrivateFileAtomicSync } from "../server/private-files.js"; -import { findExecutable } from "../executable-resolution/executable-resolution.js"; +import { + findExecutable, + findExecutableWithoutProbing, +} from "../executable-resolution/executable-resolution.js"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js"; @@ -33,6 +44,20 @@ export interface TerminalExitInfo { lastOutputLines: string[]; } +// The field is assigned explicitly rather than declared as a constructor +// parameter property: the terminal worker loads this module under +// --experimental-strip-types, which erases types but cannot emit the +// assignment a parameter property implies. +export class TerminalCommandNotFoundError extends Error { + readonly command: string; + + constructor(command: string) { + super(`${command}: command not found`); + this.name = "TerminalCommandNotFoundError"; + this.command = command; + } +} + export interface TerminalCommandFinishedInfo { exitCode: number | null; } @@ -886,6 +911,62 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] { return lines.slice(-limit); } +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) { + return false; + } + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +export interface AssertTerminalCommandCanStartInput { + command: string | undefined; + // The terminal's own cwd and environment, not the daemon's: execve in the + // child resolves against those, so anything else produces false negatives. + cwd: string; + env: Record; + platform?: NodeJS.Platform; +} + +// node-pty spawns directly through execve on POSIX, with no shell, so a command +// that does not exist fails inside the spawn helper and surfaces as exit code 1 +// with no output -- indistinguishable from `false` or any program that simply +// failed. Resolving it up front is the only signal that separates "cannot start" +// from "ran and exited non-zero", and it lets the create report the failure +// instead of handing back a terminal that dies immediately. Windows keeps +// resolving inside resolveTerminalSpawnCommand, which has its own shim handling +// and its own fallback for an unresolved command. +export async function assertTerminalCommandCanStart( + input: AssertTerminalCommandCanStartInput, +): Promise { + const { command, cwd, env } = input; + const platform = input.platform ?? process.platform; + if (command === undefined || platform === "win32") { + return; + } + + // A path-ish command is resolved by execve against the child's cwd, never + // through PATH and never against the daemon's working directory. + if (command.includes("/")) { + if (isExecutableFile(resolvePath(cwd, command))) { + return; + } + throw new TerminalCommandNotFoundError(command); + } + + const resolved = await findExecutableWithoutProbing(command, { + path: env[getPathEnvKey(env)], + cwd, + }); + if (!resolved) { + throw new TerminalCommandNotFoundError(command); + } +} + export async function createTerminal(options: CreateTerminalOptions): Promise { const { cwd, @@ -945,19 +1026,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + + // The worker delivers all three messages in one tick, so the awaiting + // createTerminal continuation resumes only after the exit was processed. + const terminal = { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }; + const state = createTerminalState(); + const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] }; + worker.emitWorkerMessage({ type: "terminalCreated", terminal, state }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: { terminal, state }, + }); + worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo }); + + const session = await created; + + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(await manager.getTerminals("/workspace")).toEqual([]); + expect(session.getExitInfo()).toEqual(exitInfo); + expect(snapshots).toEqual([ + { cwd: "/workspace", terminalIds: ["terminal-a"] }, + { cwd: "/workspace", terminalIds: [] }, + ]); +}); + +it("replays the exit to a listener attached after a create that lost the race", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + + const terminal = { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + workspaceId: "ws-test", + activity: null, + }; + const state = createTerminalState(); + const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] }; + worker.emitWorkerMessage({ type: "terminalCreated", terminal, state }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: { terminal, state }, + }); + worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo }); + + const session = await created; + + // The session controller subscribes only after createTerminal resolves; that + // subscription is what turns into a terminal_exit for the client. + const observedExits: Array<{ exitCode: number | null }> = []; + session.onExit((info) => { + observedExits.push({ exitCode: info.exitCode }); + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(observedExits).toEqual([{ exitCode: 0 }]); +}); + +it("rejects createTerminal and registers nothing when the worker cannot start the command", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; + manager.subscribeTerminalsChanged((event) => { + snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) }); + }); + + const created = manager.createTerminal({ + id: "terminal-a", + cwd: "/workspace", + workspaceId: "ws-test", + command: "paseo-qa-absent-codex", + }); + const request = worker.sentMessages.find((message) => message.type === "createTerminal"); + if (!request) { + throw new Error("createTerminal request not sent"); + } + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: false, + error: "paseo-qa-absent-codex: command not found", + }); + + await expect(created).rejects.toThrow("paseo-qa-absent-codex: command not found"); + expect(manager.getTerminal("terminal-a")).toBeUndefined(); + expect(await manager.getTerminals("/workspace")).toEqual([]); + expect(snapshots).toEqual([]); +}); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 0f0243d7a8..ca84cfe622 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -156,6 +156,9 @@ export function createWorkerTerminalManager( const recordsById = new Map(); const terminalIdsByCwd = new Map>(); const terminalActivityTokenById = new Map(); + // Creation owns a reference to the session independently of the live-terminal + // index. Only terminalCreated registers it; terminalExit removes it permanently. + const pendingCreationsById = new Map(); const terminalsChangedListeners = new Set(); const terminalActivityListeners = new Set(); const terminalWorkspaceContributionChangedListeners = @@ -542,10 +545,14 @@ export function createWorkerTerminalManager( function handleWorkerEvent(message: TerminalWorkerToParentMessage): void { switch (message.type) { case "terminalCreated": { - registerRecord({ + const session = registerRecord({ info: asRequiredWorkerTerminalInfo(message.terminal), state: message.state, }); + const pendingCreation = pendingCreationsById.get(message.terminal.id); + if (pendingCreation) { + pendingCreation.session = session; + } emitTerminalsChanged({ cwd: message.terminal.cwd, terminals: listTerminalItemsForCwd(message.terminal.cwd), @@ -686,12 +693,10 @@ export function createWorkerTerminalManager( const activityToken = createActivityToken(); const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null; terminalActivityTokenById.set(terminalId, activityToken); - let result: { - terminal: RequiredWorkerTerminalInfo; - state: TerminalState; - }; + const creation = { session: null as TerminalSession | null }; + pendingCreationsById.set(terminalId, creation); try { - result = (await sendRequest({ + await sendRequest({ type: "createTerminal", options: { ...options, @@ -699,16 +704,17 @@ export function createWorkerTerminalManager( activityToken, activityUrl: terminalActivityUrl, }, - })) as { - terminal: RequiredWorkerTerminalInfo; - state: TerminalState; - }; + }); + if (!creation.session) { + throw new Error("Terminal worker completed creation without a terminalCreated event"); + } + return creation.session; } catch (error) { terminalActivityTokenById.delete(terminalId); throw error; + } finally { + pendingCreationsById.delete(terminalId); } - const session = registerRecord({ info: result.terminal, state: result.state }); - return session; }, registerCwdEnv(options: { cwd: string; env: Record }): void { From f3ddf99fe35020353954e63829ed64e87f97d58a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 06:30:13 +0700 Subject: [PATCH 02/17] fix(app): restore fullscreen composer keyboard motion and clearing (#6902) * fix(app): keep fullscreen composer in the keyboard dock * fix(app): preserve composer caret and scope keyboard actions --- docs/floating-panels.md | 34 ++ .../e2e/browser/composer-autocomplete.spec.ts | 35 +- .../e2e/browser/composer-whitespace.spec.ts | 327 ++++++++++++++---- .../new-workspace-launch-terminal.spec.ts | 129 ++++++- packages/app/e2e/support/helpers/composer.ts | 49 +++ packages/app/src/app/_layout.tsx | 2 +- packages/app/src/app/new.tsx | 37 +- .../ui/text-input/text-input.native.tsx | 7 + .../ui/text-input/text-input.web.tsx | 4 + .../app/src/components/ui/text-input/types.ts | 1 + .../app/src/composer/dock/index.native.tsx | 94 ++++- packages/app/src/composer/dock/index.tsx | 45 ++- .../composer/dock/internal/capacity.test.ts | 15 + .../src/composer/dock/internal/capacity.ts | 19 + .../dock/internal/fullscreen.native.tsx | 39 +++ .../src/composer/dock/internal/fullscreen.tsx | 89 +++++ .../composer/draft/input-draft.live.test.tsx | 3 + .../app/src/composer/draft/input-draft.ts | 8 +- .../app/src/composer/draft/workspace-tab.tsx | 10 +- .../composer/editing-session/index.test.ts | 97 +++--- .../src/composer/editing-session/index.tsx | 114 ++++-- packages/app/src/composer/index.tsx | 72 +++- .../composer/input/fullscreen-exit.native.ts | 18 + .../app/src/composer/input/fullscreen-exit.ts | 23 ++ .../composer/input/fullscreen-host.native.tsx | 57 --- .../src/composer/input/fullscreen-host.tsx | 77 ----- packages/app/src/composer/input/input.tsx | 202 +++++------ .../app/src/composer/input/presentation.tsx | 147 ++++---- packages/app/src/composer/input/state.test.ts | 28 +- packages/app/src/composer/input/state.ts | 28 ++ packages/app/src/composer/types.ts | 6 +- packages/app/src/panels/agent-panel.tsx | 23 +- .../app/src/screens/new-workspace-screen.tsx | 77 +++-- .../screens/workspace/workspace-screen.tsx | 25 +- 34 files changed, 1376 insertions(+), 565 deletions(-) create mode 100644 packages/app/src/composer/dock/internal/fullscreen.native.tsx create mode 100644 packages/app/src/composer/dock/internal/fullscreen.tsx create mode 100644 packages/app/src/composer/input/fullscreen-exit.native.ts create mode 100644 packages/app/src/composer/input/fullscreen-exit.ts delete mode 100644 packages/app/src/composer/input/fullscreen-host.native.tsx delete mode 100644 packages/app/src/composer/input/fullscreen-host.tsx diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 73df983a50..cb51c8f7dd 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -198,6 +198,40 @@ viewport keeps the last keyboard reservation until the next opening replaces it, so a capped draft keeps one height through every keyboard transition instead of expanding on close and shrinking again on open. +Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock +fullscreen` fills the stationary viewport with the composer slot over the +still-mounted content, paints its own surface under the safe areas, and turns +the surface translation off. The composer's single tree keeps the editing +surface at the same position in both modes, so the mounted editor, its native +buffer, selection, and focus survive the toggle and Send's clear always reaches +the live input. The one remount left is a viewport crossing the compact +breakpoint: the app shell swaps its gesture wrapper and the workspace screen +mounts tabs in different trees, so every screen below remounts. The draft owner +restores the text and the editing session restores the caret and focus intent. +New workspace keys its sessions by draft in the app-level store for the same +reason. The centered form honors fullscreen by style alone: no centering, no +translation, setup fields hidden, slot filling the stationary viewport. The +global exit action (Escape, hardware back) is registered only for the active +composer: a retained tab keeps its fullscreen editor mounted while hidden, the +sheet suspends pane focus, and the composer gives up input from the moment the +user asks for the compact agent list or explorer, before the panel's motion +settles. Pane focus itself stays put under those panels: the agent panel reads +it as "the user is in this chat" for attention clearing, and opening the agent +list must not clear the attention it shows. +The band beside the inline editor where the fullscreen glyph sits is the input's +own right padding, so a tap anywhere in it focuses the editor whether or not the +glyph shows. On web the slot is a fixed box over the visible viewport, which is +also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard +height as a bottom margin at move start and end (boundary-time, like the +capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per +frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap +and without animating the editor's height. The screen that owns the chrome +unmounts it: the workspace screen reads `useComposerTabFullscreen` for the +active tab and New workspace reads the mounted composer's session. Hosts that +render the dock pass the same `ComposerEditingSession` to the `Composer` so the +dock, chrome, and editor agree on one fullscreen state. Escape (web overlay +registration, focus untouched) and the Android back button leave fullscreen. + Keep setup fields out of the composer's shrink chain. New workspace supplies its setup fields as the first dock child so they move up under the header as the draft grows. Putting the fields and composer in one bounded, shrinkable column made diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index d30e69b18c..d83046d8b2 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -9,7 +9,12 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; -import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar"; +import { + closeMobileAgentSidebar, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + openMobileAgentSidebar, +} from "../support/helpers/sidebar"; import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace"; const TEST_COMMANDS = [ @@ -96,16 +101,6 @@ interface PopoverFrameRecorderWindow extends Window { __stopComposerAutocompleteFrameRecorder?: () => void; } -async function getTopTestIdAtPoint(page: Page, x: number, y: number) { - return page.evaluate( - ([pointX, pointY]) => { - const element = document.elementFromPoint(pointX, pointY); - return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null; - }, - [x, y], - ); -} - async function installListCommandsStub(page: Page): Promise { await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { const server = ws.connectToServer(); @@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => { timeout: 30_000, }); - await page.getByRole("button", { name: "Open menu" }).click(); + // The sidebar covers the composer, so the composer gives up its popover while the + // sidebar is open and brings it back once the sidebar closes. + await openMobileAgentSidebar(page); await expectMobileAgentSidebarVisible(page); + await expect(popover).toHaveCount(0); - const popoverBox = await popover.boundingBox(); - expect(popoverBox).not.toBeNull(); - - const topTestId = await getTopTestIdAtPoint( - page, - popoverBox!.x + popoverBox!.width / 2, - popoverBox!.y + popoverBox!.height / 2, - ); - - expect(topTestId).not.toBe("composer-autocomplete-popover"); + await closeMobileAgentSidebar(page); + await expectMobileAgentSidebarHidden(page); + await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible(); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 35611a32c0..0c74fc7630 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -20,6 +20,9 @@ import { queueComposerWithButton, configureComposerQueueSend, activateRetainedComposerTab, + switchComposerTabWithKeyboard, + expectWorkspaceHeaderHidden, + expectWorkspaceHeaderVisible, recordVisibleComposerGeometry, expectNoCollapsedVisibleComposer, expectAttachmentPill, @@ -27,8 +30,14 @@ import { startRunningMockAgent, composerLocator, expectComposerVisible, + expectComposerFullscreenFitsVisibleViewport, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + closeMobileAgentSidebar, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, +} from "../support/helpers/sidebar"; async function composerHeight(page: Page): Promise { return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height); @@ -156,6 +165,36 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } } }); +async function swipeOpenMobileAgentSidebar(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(8, y); + await page.mouse.down(); + await page.mouse.move(40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); + await page.mouse.up(); +} + +/** Freeze the page's timers and animation frames until `page.clock.resume()`. */ +async function pausePageClock(page: Page): Promise { + const now = Date.now(); + await page.clock.install({ time: now }); + await page.clock.pauseAt(now); +} + +/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */ +async function expectFullscreenErrorAboveSend(page: Page): Promise { + const frame = page.getByTestId("composer-fullscreen"); + const alertBox = (await frame.getByRole("alert").boundingBox())!; + const sendBox = (await page + .getByRole("button", { name: "Send message", exact: true }) + .boundingBox())!; + const editorBox = (await composerLocator(page).boundingBox())!; + expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height); + expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y); +} + async function writeCompactDraft(page: Page, text: string): Promise { await composerLocator(page).fill(text); await expect(composerLocator(page)).toHaveValue(text); @@ -188,47 +227,107 @@ test("compact fullscreen editing preserves the live draft and selection and stay await resizeComposerViewport(page, "portrait"); await openAgentRoute(page, agent); await expectComposerVisible(page); - await attachComposerTestImage(page); - await expectAttachmentPill(page, "composer-image-attachment-pill"); - await writeCompactDraft(page, "First line\nSecond line"); - await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0); - await writeCompactDraft(page, "First line\nSecond line\nThird line"); - await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible(); - await writeCompactDraft( - page, - "A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat( - 4, - ), - ); - await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible(); - await selectComposerDraftRange(page, 4, 17); - await enterComposerFullscreen(page); - await expectComposerFullscreenControls(page); - await expectComposerDraftSelection(page, 4, 17); - await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); - await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line"); - await selectComposerDraftRange(page, 3, 12); - await exitComposerFullscreen(page); - await expect(composerLocator(page)).toHaveValue( - "Edited fullscreen draft\nSecond line\nThird line", - ); - await expectComposerDraftSelection(page, 3, 12); - await expectAttachmentPill(page, "composer-image-attachment-pill"); - await expect(composerLocator(page)).toBeFocused(); - await enterComposerFullscreen(page); - await writeCompactDraft(page, "Short draft"); - await expectComposerFullscreenControls(page); - await selectComposerDraftRange(page, 0, 5); - await resizeComposerViewport(page, "landscape"); - await expectComposerFullscreenControls(page); - await expectComposerDraftSelection(page, 0, 5); - await expect(composerLocator(page)).toHaveValue("Short draft"); - await expect(composerLocator(page)).toBeFocused(); + await test.step("the affordance appears after two rendered lines, including soft wraps", async () => { + await attachComposerTestImage(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await writeCompactDraft(page, "First line\nSecond line"); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0); + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible(); + await writeCompactDraft( + page, + "A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat( + 4, + ), + ); + await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible(); + }); + await test.step("fullscreen covers the header and keeps the live draft and selection", async () => { + await selectComposerDraftRange(page, 4, 17); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectWorkspaceHeaderHidden(page); + await expectComposerDraftSelection(page, 4, 17); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line"); + await selectComposerDraftRange(page, 3, 12); + }); + await test.step("exit restores the inline composer with the fullscreen edits", async () => { + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + await expect(composerLocator(page)).toHaveValue( + "Edited fullscreen draft\nSecond line\nThird line", + ); + await expectComposerDraftSelection(page, 3, 12); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await expect(composerLocator(page)).toBeFocused(); + }); + await test.step("sending inline after a fullscreen edit clears the visible editor", async () => { + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await writeCompactDraft(page, "Typed after the fullscreen cycle"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + }); + await test.step("sending inside fullscreen clears the editor and keeps fullscreen open", async () => { + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await writeCompactDraft(page, "Short draft"); + await expectComposerFullscreenControls(page); + await selectComposerDraftRange(page, 0, 5); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await expectComposerDraftSelection(page, 0, 5); + await expect(composerLocator(page)).toHaveValue("Short draft"); + await expect(composerLocator(page)).toBeFocused(); + await resizeComposerViewport(page, "portrait"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectComposerFullscreenControls(page); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + }); + await test.step("typing and sending keep working after leaving fullscreen again", async () => { + await writeCompactDraft(page, "Typed inside fullscreen"); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen"); + await writeCompactDraft(page, "Typed inside fullscreen, finished inline"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await writeCompactDraft(page, "One more inline message"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + }); + } finally { + await agent.cleanup(); + } +}); + +test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-remount-caret-", + title: "Remount caret", + }); + try { await resizeComposerViewport(page, "portrait"); - await sendComposerWithButton(page); - await expect(composerLocator(page)).toHaveValue(""); - await expectComposerFullscreenControls(page); - await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "Short draft"); + await selectComposerDraftRange(page, 5, 5); + // The draft owner publishes web edits after the next paint. A rotation inside that + // window remounts the editor before the edit has reached the draft store; the paused + // clock holds the page there instead of racing the frame. + await pausePageClock(page); + await composerLocator(page).press("X"); + await resizeComposerViewport(page, "landscape"); + await page.clock.resume(); + await expect(composerLocator(page)).toHaveValue("ShortX draft"); + await expectComposerDraftSelection(page, 6, 6); + await expect(composerLocator(page)).toBeFocused(); + await composerLocator(page).press("Y"); + await expect(composerLocator(page)).toHaveValue("ShortXY draft"); } finally { await agent.cleanup(); } @@ -253,6 +352,7 @@ test("fullscreen submit rejection retains the editable draft and supports retry" await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText( "Requested mock prompt rejection", ); + await expectFullscreenErrorAboveSend(page); await expect(composerLocator(page)).toHaveValue(draft); await expect(composerLocator(page)).toBeEditable(); await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); @@ -264,6 +364,76 @@ test("fullscreen submit rejection retains the editable draft and supports retry" } }); +async function blurComposer(page: Page): Promise { + await composerLocator(page).blur(); + await expect(composerLocator(page)).not.toBeFocused(); +} + +/** Mid-band at the given height and near the editor's bottom edge. */ +async function reservedBandPoints( + page: Page, + bandX: number, + topY: number, +): Promise> { + const editor = (await composerLocator(page).boundingBox())!; + return [ + { x: bandX, y: topY }, + { x: bandX, y: editor.y + editor.height - 6 }, + ]; +} + +test("the reserved band beside the inline editor focuses it", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-band-focus-", + title: "Composer band focus", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true }); + const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true }); + const longDraft = "Band first line\nBand second line\nBand third line"; + await writeCompactDraft(page, longDraft); + await expect(fullscreenButton).toBeVisible(); + const button = (await fullscreenButton.boundingBox())!; + const bandX = button.x + button.width / 2; + + await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => { + for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) { + await blurComposer(page); + await page.mouse.click(point.x, point.y); + await expect(composerLocator(page)).toBeFocused(); + await expect(composerLocator(page)).toHaveValue(longDraft); + await expect(exitButton).toHaveCount(0); + } + }); + + await test.step("the button itself still toggles fullscreen", async () => { + await fullscreenButton.click(); + await expectComposerFullscreenControls(page); + await exitComposerFullscreen(page); + await expect(exitButton).toHaveCount(0); + await expect(composerLocator(page)).toHaveValue(longDraft); + }); + + await test.step("with no fullscreen button, the whole band still focuses the editor", async () => { + await writeCompactDraft(page, "Short"); + await expect(fullscreenButton).toHaveCount(0); + const editor = (await composerLocator(page).boundingBox())!; + for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) { + await blurComposer(page); + await page.mouse.click(point.x, point.y); + await expect(composerLocator(page)).toBeFocused(); + await expect(composerLocator(page)).toHaveValue("Short"); + await expect(exitButton).toHaveCount(0); + } + }); + } finally { + await agent.cleanup(); + } +}); + test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({ page, }) => { @@ -333,15 +503,28 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw const draft = "Live first line\nLive second line\nLive third line"; await writeCompactDraft(page, draft); await enterComposerFullscreen(page); + await expectWorkspaceHeaderHidden(page); await selectComposerDraftRange(page, 4, 17); - await activateRetainedComposerTab(page, first.title); + // Fullscreen covers the compact header, so a phone leaves the tab by exiting first; + // a desktop-width window still has the tab shortcut. + await resizeComposerViewport(page, "landscape"); + await switchComposerTabWithKeyboard(page, "previous"); await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + // The exit action belongs to the active pane: Escape here leaves the hidden tab alone. + await composerLocator(page).press("Escape"); + await switchComposerTabWithKeyboard(page, "next"); + await expectComposerFullscreenControls(page); + await switchComposerTabWithKeyboard(page, "previous"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await resizeComposerViewport(page, "portrait"); + await expectWorkspaceHeaderVisible(page); await writeCompactDraft(page, "Other tab draft"); await shrinkVisibleViewport(page); await activateRetainedComposerTab(page, second.title); await expectComposerFullscreenControls(page); + await expectWorkspaceHeaderHidden(page); await expect(composerLocator(page)).toHaveValue(draft); - await expectFullscreenInsideVisibleViewport(page); + await expectComposerFullscreenFitsVisibleViewport(page); await expectComposerDraftSelection(page, 4, 17); await expect(composerLocator(page)).toBeFocused(); expect(await pasteComposerTestImage(page)).toBe(true); @@ -352,6 +535,36 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw } }); +test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-sidebar-", + title: "Fullscreen under sidebar", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + const draft = "Line one\nLine two\nLine three"; + await writeCompactDraft(page, draft); + await enterComposerFullscreen(page); + await expectWorkspaceHeaderHidden(page); + // Fullscreen hides the header's menu button; the edge swipe still opens the list over it. + await swipeOpenMobileAgentSidebar(page); + await expectMobileAgentSidebarVisible(page); + // The list covers the pane, so the editor's exit action is not armed. + await page.keyboard.press("Escape"); + await closeMobileAgentSidebar(page); + await expectMobileAgentSidebarHidden(page); + await expectComposerFullscreenControls(page); + await expect(composerLocator(page)).toHaveValue(draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + } finally { + await agent.cleanup(); + } +}); + test("retained inline composers keep usable capacity through hidden tab geometry", async ({ page, }) => { @@ -408,30 +621,6 @@ async function panVisibleViewport(page: Page): Promise { await cdp.detach(); } -async function expectFullscreenInsideVisibleViewport(page: Page): Promise { - await expect - .poll(() => - page.getByTestId("composer-fullscreen").evaluate((element) => { - const viewport = window.visualViewport!; - const frame = element.getBoundingClientRect(); - const send = element.querySelector('[aria-label="Send message"]')!.getBoundingClientRect(); - return { - frameFits: - Math.abs(frame.top - viewport.offsetTop) < 1 && - Math.abs(frame.left - viewport.offsetLeft) < 1 && - Math.abs(frame.height - viewport.height) < 1 && - Math.abs(frame.width - viewport.width) < 1, - sendFits: - send.top >= viewport.offsetTop && - send.bottom <= viewport.offsetTop + viewport.height && - send.left >= viewport.offsetLeft && - send.right <= viewport.offsetLeft + viewport.width, - }; - }), - ) - .toEqual({ frameFits: true, sendFits: true }); -} - test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => { const agent = await seedMockAgentWorkspace({ repoPrefix: "composer-visible-viewport-", @@ -443,11 +632,11 @@ test("fullscreen Send follows the visible viewport when it shrinks and pans", as await expectComposerVisible(page); await writeCompactDraft(page, "First line\nSecond line\nThird line"); await enterComposerFullscreen(page); - await expectFullscreenInsideVisibleViewport(page); + await expectComposerFullscreenFitsVisibleViewport(page); await shrinkVisibleViewport(page); - await expectFullscreenInsideVisibleViewport(page); + await expectComposerFullscreenFitsVisibleViewport(page); await panVisibleViewport(page); - await expectFullscreenInsideVisibleViewport(page); + await expectComposerFullscreenFitsVisibleViewport(page); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts index a4fbd15edf..d393966ee8 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -1,7 +1,18 @@ -import { expect, test } from "../support/fixtures"; +import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -import { openNewWorkspaceComposer } from "../support/helpers/new-workspace"; +import { + expectNewWorkspaceDraft, + fillNewWorkspaceDraft, + openNewWorkspaceComposer, +} from "../support/helpers/new-workspace"; +import { + composerLocator, + enterComposerFullscreen, + exitComposerFullscreen, + expectComposerFullscreenControls, + resizeComposerViewport, +} from "../support/helpers/composer"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { expectTerminalLaunchSelected, @@ -145,3 +156,117 @@ test.describe("New workspace: launching a terminal", () => { }); }); }); + +async function expectNewWorkspaceHeaderHidden(page: Page): Promise { + await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0); +} + +async function expectNewWorkspaceHeaderVisible(page: Page): Promise { + await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible(); +} + +/** + * Fullscreen fills the content area top to bottom and out to the right edge (a pinned + * sidebar keeps its column), covers the form, and shows only exit and the mode's submit. + */ +async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise { + const frame = page.getByTestId("composer-fullscreen"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible(); + await expect(frame.getByRole("button")).toHaveCount(2); + await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible(); + await expectNewWorkspaceHeaderHidden(page); + const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!; + await expect + .poll(() => + frame.evaluate( + (element, point) => { + const viewport = window.visualViewport!; + const box = element.getBoundingClientRect(); + const covering = document.elementFromPoint(point.x, point.y); + return { + fillsHeight: + Math.abs(box.top - viewport.offsetTop) < 1 && + Math.abs(box.height - viewport.height) < 1, + reachesRightEdge: + Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 && + box.left >= viewport.offsetLeft, + coversForm: element.contains(covering), + }; + }, + { x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 }, + ), + ) + .toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true }); +} + +test.describe("New workspace: fullscreen composer across the compact breakpoint", () => { + let workspace: SeededWorkspace; + + test.beforeEach(async () => { + workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" }); + }); + + test.afterEach(async () => { + await workspace?.cleanup(); + }); + + test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({ + page, + }) => { + test.setTimeout(90_000); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + const projectPicker = page.getByRole("button", { name: "Workspace project" }); + + await test.step("chat: fullscreen covers the centered form and exits back into it", async () => { + await resizeComposerViewport(page, "portrait"); + const draft = "Chat first line\nChat second line\nChat third line"; + await fillNewWorkspaceDraft(page, draft); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectNewWorkspaceHeaderHidden(page); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await expectNewWorkspaceFullscreen(page, "Create"); + // The desktop form's own import button is dropped from the fullscreen slot. + await expect( + page.getByTestId("new-workspace-import-session").filter({ visible: true }), + ).toHaveCount(0); + await composerLocator(page).press("End"); + await composerLocator(page).pressSequentially(" wide"); + await exitComposerFullscreen(page); + await expectNewWorkspaceHeaderVisible(page); + await expect(projectPicker).toBeVisible(); + await expect( + page.getByTestId("new-workspace-import-session").filter({ visible: true }), + ).toBeVisible(); + await expectNewWorkspaceDraft(page, `${draft} wide`); + }); + + // Terminal mode and its prompt are screen state, and the shell remounts the screen + // at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone + // layout only. + await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => { + await resizeComposerViewport(page, "portrait"); + // The compact launch control opens a sheet, not the desktop menu. + await launchTrigger(page).click(); + const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name }); + await option.click(); + await expect(option).not.toBeVisible(); + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); + const prompt = "Prompt first line\nPrompt second line\nPrompt third line"; + await fillTerminalPrompt(page, prompt); + await enterComposerFullscreen(page); + await expect(terminalPromptInput(page)).toBeVisible(); + await expectNewWorkspaceFullscreen(page, "Launch"); + await exitComposerFullscreen(page); + await expectNewWorkspaceHeaderVisible(page); + await expect(projectPicker).toBeVisible(); + await expect(terminalPromptInput(page)).toHaveValue(prompt); + }); + }); +}); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index 01b9a39d58..efd9266657 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -355,6 +355,12 @@ export async function resizeComposerViewport( short: { width: 390, height: 390 }, }; await page.setViewportSize(sizes[orientation]); + // Before the app has loaded there is no layout to wait for. + if (page.url() === "about:blank") return; + // Crossing the compact breakpoint remounts the shell and the editor. Anything that + // follows (a shortcut, a locator) belongs to the layout the new size implies, so + // wait for that layout before returning. + await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1); } export async function selectComposerDraftRange( page: Page, @@ -390,6 +396,33 @@ export async function expectComposerFullscreenControls(page: Page): Promise { + await expect + .poll(() => + page.getByTestId("composer-fullscreen").evaluate((element, label) => { + const viewport = window.visualViewport!; + const frame = element.getBoundingClientRect(); + const send = element.querySelector(`[aria-label="${label}"]`)!.getBoundingClientRect(); + return { + frameFits: + Math.abs(frame.top - viewport.offsetTop) < 1 && + Math.abs(frame.left - viewport.offsetLeft) < 1 && + Math.abs(frame.height - viewport.height) < 1 && + Math.abs(frame.width - viewport.width) < 1, + sendFits: + send.top >= viewport.offsetTop && + send.bottom <= viewport.offsetTop + viewport.height && + send.left >= viewport.offsetLeft && + send.right <= viewport.offsetLeft + viewport.width, + }; + }, submitLabel), + ) + .toEqual({ frameFits: true, sendFits: true }); +} export async function sendComposerWithButton(page: Page): Promise { await page.getByRole("button", { name: "Send message", exact: true }).click(); } @@ -405,6 +438,22 @@ export async function configureComposerQueueSend(page: Page): Promise { /** Drive the real tab chooser's commands even when fullscreen covers its pointer surface. * This exercises retained activity changes, not a claim that covered chrome is clickable. */ +/** Needs a desktop-width viewport: compact layouts have no keyboard shortcuts. */ +export async function switchComposerTabWithKeyboard( + page: Page, + direction: "previous" | "next", +): Promise { + await page.keyboard.press( + direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight", + ); +} +export async function expectWorkspaceHeaderHidden(page: Page): Promise { + await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0); +} +export async function expectWorkspaceHeaderVisible(page: Page): Promise { + await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible(); +} + export async function activateRetainedComposerTab(page: Page, title: string): Promise { await page .getByTestId("workspace-tab-switcher-trigger") diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 72989bd83d..2d6525b27f 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -665,7 +665,7 @@ function MobileGestureWrapper({ return ( - + {children} diff --git a/packages/app/src/app/new.tsx b/packages/app/src/app/new.tsx index 281ec33555..67db3c2c96 100644 --- a/packages/app/src/app/new.tsx +++ b/packages/app/src/app/new.tsx @@ -1,6 +1,15 @@ import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { + ComposerEditingScope, + WorkspaceComposerEditingSessionsProvider, +} from "@/composer/editing-session"; import { NewWorkspaceScreen } from "@/screens/new-workspace-screen"; +import { buildNewWorkspaceDraftKey } from "@/stores/draft-keys"; + +/** New workspace drafts share one editing-session home, keyed like a tab by draft. */ +const NEW_WORKSPACE_SESSIONS_KEY = "new-workspace"; export default function NewWorkspaceRoute() { const params = useLocalSearchParams<{ @@ -23,16 +32,28 @@ export default function NewWorkspaceRoute() { draftId ?? null, ]); + // The shell remounts this route when the viewport crosses the compact breakpoint; + // the draft's editing session (fullscreen, retained caret) lives above the shell. + const draftKey = buildNewWorkspaceDraftKey(draftId); + const sessionTabIds = useMemo(() => [draftKey], [draftKey]); + return ( - + + + + + ); } diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index 243e813f26..4ff773cefa 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -104,6 +104,13 @@ export const EditingTextInput = forwardRef { + if (inputRef.current?.setSelection) { + inputRef.current.setSelection(selection.start, selection.end); + return; + } + inputRef.current?.setNativeProps?.({ selection }); + }, reset: () => { textRef.current = ""; const autoFocus = inputRef.current?.isFocused?.() ?? false; diff --git a/packages/app/src/components/ui/text-input/text-input.web.tsx b/packages/app/src/components/ui/text-input/text-input.web.tsx index 30ee0c6eba..021e3b8e0c 100644 --- a/packages/app/src/components/ui/text-input/text-input.web.tsx +++ b/packages/app/src/components/ui/text-input/text-input.web.tsx @@ -77,6 +77,10 @@ export const EditingTextInput = forwardRef { + const input = inputRef.current as WebTextInputElement | null; + input?.setSelectionRange?.(selection.start, selection.end); + }, reset: () => { textRef.current = ""; const input = inputRef.current as WebTextInputElement | null; diff --git a/packages/app/src/components/ui/text-input/types.ts b/packages/app/src/components/ui/text-input/types.ts index 9f746bd656..fbc14e4726 100644 --- a/packages/app/src/components/ui/text-input/types.ts +++ b/packages/app/src/components/ui/text-input/types.ts @@ -8,6 +8,7 @@ export interface EditingTextInputHandle { isFocused(): boolean; getText(): string; replaceText(text: string, selection?: { start: number; end: number }): void; + setSelection(selection: { start: number; end: number }): void; /** Clear the editor and reset its intrinsic layout, preserving focus intent. */ reset(): void; getNativeRef(): unknown; diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index a668c00743..4b6407c872 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -1,5 +1,6 @@ import { ComposerDockBackground } from "./internal/background"; export { ComposerDockBackground } from "./internal/background"; +export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen"; import { ScrollView } from "@/components/ui/scroll-view"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -8,22 +9,22 @@ import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings"; import { KeyboardTranslateView } from "@/keyboard/shift"; import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native"; +import { StyleSheet as ThemedStyleSheet } from "react-native-unistyles"; import Animated, { useAnimatedStyle, useSharedValue, - useDerivedValue, useAnimatedReaction, type SharedValue, } from "react-native-reanimated"; import { useKeyboardShift } from "@/keyboard/shift"; import { resolveCenteredClearance, - resolveInlineComposerCapacity, + resolveComposerSlotCapacity, updateComposerCapacity, type ComposerCapacity, } from "./internal/capacity"; -const ViewportCapacity = createContext | null>(null); +const ViewportSizing = createContext | null>(null); interface ComposerViewportProps extends ViewProps { bottomInset?: number; @@ -55,7 +56,6 @@ function ComposerViewport({ }, [], ); - const capacity = useDerivedValue(() => sizing.value?.capacity); const measureViewport = useCallback( (event: LayoutChangeEvent) => { measuredHeight.value = event.nativeEvent.layout.height; @@ -66,20 +66,24 @@ function ComposerViewport({ return ( - {children} + {children} ); } /** Bound the complete composer (including its controls), not just the text input. */ -function ComposerViewportContent({ style, ...props }: ViewProps) { +function ComposerViewportContent({ + style, + fullscreen = false, + ...props +}: ViewProps & { fullscreen?: boolean }) { const compact = useIsCompactFormFactor(); - const capacity = useContext(ViewportCapacity); - if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport"); + const sizing = useContext(ViewportSizing); + if (!sizing) throw new Error("ComposerViewportContent requires ComposerViewport"); const constraint = useAnimatedStyle(() => ({ // Only viewport changes and keyboard start/end events trigger layout. Motion // stays in KeyboardTranslateView and never changes this constraint per frame. - maxHeight: resolveInlineComposerCapacity(capacity.value, compact), + maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }), })); return ( @@ -108,37 +112,71 @@ function useCenteredClearance(safeAreaBottom: number) { interface ComposerDockProps { children: [ReactNode, ReactNode, ReactNode?]; centered?: boolean; + /** + * Fullscreen is a layout mode of the same mounted composer: the slot fills the + * stationary viewport over the content and lays out the keyboard itself, so the + * surface stops translating. The screen that owns the chrome hides it. + */ + fullscreen?: boolean; } /** The stationary viewport, translated surface, and bounded composer are one owner. */ export function ComposerDock({ children: [content, composer, overlay], centered = false, + fullscreen = false, }: ComposerDockProps) { const insets = useSafeAreaInsets(); const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings); const centeredClearance = useCenteredClearance(insets.bottom); // Preserve the existing centered form's visual balance on tablets. - const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0; + const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0; + const fullscreenSurface = [ + themedStyles.fullscreenComposer, + { + paddingTop: insets.top, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + paddingRight: insets.right, + }, + ]; if (centered) { + // Same tree in both modes. Fullscreen stops centering and translating, hides the + // setup fields, and lets the composer slot take the whole stationary viewport. return ( - - + + {content} - - {composer} + + {composer} {overlay} @@ -148,7 +186,7 @@ export function ComposerDock({ } return ( - + - - {composer} + + + {composer} + {overlay} @@ -178,9 +227,16 @@ const dockStyles = StyleSheet.create({ surface: { flex: 1 }, content: { flex: 1, justifyContent: "flex-end" }, composer: { width: "100%", flexShrink: 1 }, + fullscreenSlot: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" }, centered: { flexShrink: 1, width: "100%" }, // Reserve the composer's own capped height before the setup scroll view shrinks. centeredComposer: { width: "100%", flexShrink: 0 }, setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, + hiddenSetup: { display: "none" }, }); + +// The fullscreen composer paints its own surface, including under the safe areas. +const themedStyles = ThemedStyleSheet.create((theme) => ({ + fullscreenComposer: { flex: 1, backgroundColor: theme.colors.surface1 }, +})); diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx index 3f41118696..421002352e 100644 --- a/packages/app/src/composer/dock/index.tsx +++ b/packages/app/src/composer/dock/index.tsx @@ -1,48 +1,69 @@ export { ComposerDockBackground } from "./internal/background"; +export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen"; import { ScrollView } from "@/components/ui/scroll-view"; -import { useState, useCallback, useMemo, type ReactNode } from "react"; +import { useState, useCallback, useMemo, useRef, type ReactNode } from "react"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { resolveInlineComposerCapacity } from "./internal/capacity"; +import { useFullscreenSlotFrame } from "./internal/fullscreen"; import { useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; interface ComposerDockProps { children: [ReactNode, ReactNode, ReactNode?]; centered?: boolean; + /** See the native dock: the same mounted composer fills the visible surface. */ + fullscreen?: boolean; } export function ComposerDock({ children: [content, composer, overlay], centered = false, + fullscreen = false, }: ComposerDockProps) { const compact = useIsCompactFormFactor(); - const [availableHeight, setAvailableHeight] = useState(); + const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>(); + const surfaceRef = useRef(null); + const fullscreenFrame = useFullscreenSlotFrame(fullscreen, surfaceRef, surfaceSize); const constraint = useMemo( - () => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }), - [availableHeight, compact], + () => ({ maxHeight: resolveInlineComposerCapacity(surfaceSize?.height, compact) }), + [surfaceSize?.height, compact], ); const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => { - const { height } = event.nativeEvent.layout; - if (height > 0) setAvailableHeight(height); + const { width, height } = event.nativeEvent.layout; + if (height > 0) setSurfaceSize({ width, height }); }, []); if (centered) { return ( - + {content} - {composer} + + {composer} + {overlay} ); } return ( - + {content} - {composer} + + {composer} + {overlay} ); @@ -52,6 +73,10 @@ const styles = StyleSheet.create((theme) => ({ surface: { flex: 1, overflow: "hidden" }, content: { flex: 1, justifyContent: "flex-end" }, composer: { width: "100%", flexShrink: 1 }, + // Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor + // never reaches the zoomed visual viewport, while a fixed box chains to the viewport + // directly. React Native Web renders `fixed`; React Native's style type omits it. + fullscreenComposer: { position: "fixed" as "absolute" }, centered: { flex: 1, alignItems: "center", diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts index 7e98b7ef3d..44e79e6ef9 100644 --- a/packages/app/src/composer/dock/internal/capacity.test.ts +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveCenteredClearance, + resolveComposerSlotCapacity, resolveInlineComposerCapacity, resolveComposerCapacity, updateComposerCapacity, @@ -19,6 +20,20 @@ describe("composer viewport", () => { expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245); expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined(); }); + it("lets a fullscreen composer fill the viewport while inline keeps its cap", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 308, + }); + expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: true })).toBe(582); + expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: false })).toBe(147); + expect(resolveComposerSlotCapacity(undefined, { compact: true, fullscreen: true })).toBe( + undefined, + ); + }); it("preserves the editing capacity when the keyboard closes", () => { const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false }; const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }); diff --git a/packages/app/src/composer/dock/internal/capacity.ts b/packages/app/src/composer/dock/internal/capacity.ts index ca4fed2991..9c460e1729 100644 --- a/packages/app/src/composer/dock/internal/capacity.ts +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -10,6 +10,8 @@ interface ComposerGeometry { export interface ComposerCapacity { keyboardReserve: number; capacity: number; + /** The stationary viewport; a fullscreen composer fills it and lays out the keyboard itself. */ + viewportHeight: number; } export function resolveComposerCapacity(input: ComposerGeometry): number { @@ -48,6 +50,7 @@ export function updateComposerCapacity( return { keyboardReserve, capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }), + viewportHeight: input.height, }; } @@ -60,3 +63,19 @@ export function resolveInlineComposerCapacity( if (capacity === undefined) return undefined; return compact ? capacity * 0.6 : capacity; } + +export interface ComposerSlotLayout { + compact: boolean; + fullscreen: boolean; +} + +/** The bound on the composer slot: inline capacity, or the whole viewport in fullscreen. */ +export function resolveComposerSlotCapacity( + sizing: ComposerCapacity | undefined, + layout: ComposerSlotLayout, +): number | undefined { + "worklet"; + if (sizing === undefined) return undefined; + if (layout.fullscreen) return sizing.viewportHeight; + return resolveInlineComposerCapacity(sizing.capacity, layout.compact); +} diff --git a/packages/app/src/composer/dock/internal/fullscreen.native.tsx b/packages/app/src/composer/dock/internal/fullscreen.native.tsx new file mode 100644 index 0000000000..9f27befa4c --- /dev/null +++ b/packages/app/src/composer/dock/internal/fullscreen.native.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { StyleSheet, type ViewProps } from "react-native"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { KeyboardTranslateView, useKeyboardShift } from "@/keyboard/shift"; + +/** + * The editing region of a composer. Inline it only continues the shrink chain. In + * fullscreen it fills the stationary frame and reserves the keyboard's space at + * motion boundaries, the same boundary-time layout the dock capacity uses: the + * keyboard start/end events write `layoutShift`; frames in between move the footer. + */ +export function ComposerFullscreenBody({ + fullscreen, + children, +}: { + fullscreen: boolean; + children: ReactNode; +}) { + const { layoutShift } = useKeyboardShift(); + const reservation = useAnimatedStyle(() => ({ + marginBottom: fullscreen ? layoutShift.value : 0, + })); + return ( + + {children} + + ); +} + +/** The Send row of a fullscreen composer rides the keyboard like the inline dock surface. */ +export function ComposerFullscreenFooter({ children, ...props }: ViewProps) { + return {children}; +} + +// Plain React Native styles: these land on Reanimated views (docs/unistyles.md). +const styles = StyleSheet.create({ + inline: { flexShrink: 1 }, + fullscreen: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/composer/dock/internal/fullscreen.tsx b/packages/app/src/composer/dock/internal/fullscreen.tsx new file mode 100644 index 0000000000..36291181c1 --- /dev/null +++ b/packages/app/src/composer/dock/internal/fullscreen.tsx @@ -0,0 +1,89 @@ +import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; +import { StyleSheet, View, type ViewProps } from "react-native"; + +/** The editing region of a composer; see the native file for the keyboard contract. */ +export function ComposerFullscreenBody({ + fullscreen, + children, +}: { + fullscreen: boolean; + children: ReactNode; +}) { + return {children}; +} + +export function ComposerFullscreenFooter({ children, ...props }: ViewProps) { + return {children}; +} + +export interface ComposerSlotFrame { + top: number; + left: number; + width: number; + height: number; +} + +/** + * Where a fullscreen composer sits, in layout-viewport coordinates for a fixed box: the + * part of the dock surface the browser's visible viewport shows. A mobile browser + * keyboard shrinks or pans that viewport without resizing the page, so the frame + * follows it instead of the layout. + */ +export function useFullscreenSlotFrame( + active: boolean, + surfaceRef: RefObject, + surfaceSize: { width: number; height: number } | undefined, +): ComposerSlotFrame | undefined { + const [frame, setFrame] = useState(); + useLayoutEffect(() => { + if (!active) { + setFrame(undefined); + return; + } + const update = () => { + const surface = surfaceRef.current as unknown as HTMLElement | null; + if (!surface) return; + setFrame(resolveVisibleSurfaceFrame(surface.getBoundingClientRect(), readVisibleViewport())); + }; + const visualViewport = window.visualViewport; + update(); + visualViewport?.addEventListener("resize", update); + visualViewport?.addEventListener("scroll", update); + window.addEventListener("resize", update); + return () => { + visualViewport?.removeEventListener("resize", update); + visualViewport?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, [active, surfaceRef, surfaceSize]); + return frame; +} + +function readVisibleViewport() { + const viewport = window.visualViewport; + return { + top: viewport?.offsetTop ?? 0, + left: viewport?.offsetLeft ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight, + }; +} + +function resolveVisibleSurfaceFrame( + surface: { top: number; left: number; right: number; bottom: number }, + viewport: { top: number; left: number; width: number; height: number }, +): ComposerSlotFrame { + const top = Math.max(viewport.top, surface.top); + const left = Math.max(viewport.left, surface.left); + return { + top, + left, + width: Math.max(0, Math.min(viewport.left + viewport.width, surface.right) - left), + height: Math.max(0, Math.min(viewport.top + viewport.height, surface.bottom) - top), + }; +} + +const styles = StyleSheet.create({ + inline: { flexShrink: 1 }, + fullscreen: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 33041cceb6..8bc31bb176 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -210,6 +210,8 @@ describe("useAgentInputDraft live contract", () => { }); const hydratedTextReplacement = getLatest().textReplacement; + // The editor applies a retained caret to the hydrated text, so hydration announces itself. + expect(hydratedTextReplacement.kind).toBe("hydrated"); await act(async () => { getLatest().editText("hello world"); @@ -223,6 +225,7 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement); + expect(getLatest().textReplacement.kind).toBe("replace"); expect(getLatest().textReplacement.text).toBe("replacement text"); await act(async () => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c64753694a..c14db9a119 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -123,10 +123,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr })); const publishTextReplacement = useCallback( - (nextText: string) => { + (kind: "hydrated" | "replace", nextText: string) => { textReplacementRevisionRef.current += 1; setTextReplacement({ - kind: "replace", + kind, key: `${draftKey}:${textReplacementRevisionRef.current}`, text: nextText, }); @@ -176,7 +176,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr (nextText: string) => { textPublication.cancel(); useDraftStore.getState().editDraftText({ draftKey, text: nextText }); - publishTextReplacement(nextText); + publishTextReplacement("replace", nextText); }, [draftKey, publishTextReplacement, textPublication], ); @@ -229,7 +229,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr await useDraftStore.getState().hydrateDraftInput({ draftKey }); if (!cancelled) { const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? ""; - publishTextReplacement(hydratedText); + publishTextReplacement("hydrated", hydratedText); setHydratedDraftKey(draftKey); } })(); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4aa22a324d..b54b344a36 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -6,6 +6,7 @@ import { ComposerDock } from "@/composer/dock"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; +import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { ComposerImportPill } from "@/composer/draft/import-pill"; import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles"; @@ -385,6 +386,8 @@ export function WorkspaceDraftAgentTab({ }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); + const editingSession = useComposerEditingSession("chat"); + const isComposerFullscreen = useComposerFullscreen(editingSession); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( COMPACT_FORM_FACTOR_WIDTH, { initialIsBelow: isCompactFormFactor }, @@ -628,10 +631,10 @@ export function WorkspaceDraftAgentTab({ return ( - + {dockContent} - {importPillPress ? ( + {importPillPress && !isComposerFullscreen ? ( @@ -655,6 +658,7 @@ export function WorkspaceDraftAgentTab({ onChangeAttachments={draftInput.setAttachments} cwd={composerState.workingDir} clearDraft={draftInput.clear} + editingSession={editingSession} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} autoFocusKey={String(draftInput.attachmentFocusRequestId)} onFocusInput={handleFocusInputCallback} @@ -672,6 +676,8 @@ const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", flexShrink: 1, + // Content-sized inline; fills the dock's fullscreen slot. + flexGrow: 1, }, }); diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts index d593e5a64e..31c3765426 100644 --- a/packages/app/src/composer/editing-session/index.test.ts +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -1,52 +1,61 @@ import { describe, expect, it } from "vitest"; -import { ComposerEditingSession } from "./index"; +import { ComposerEditingSession, ComposerEditingSessions } from "./index"; -function leaveLiveDraft(session: ComposerEditingSession) { - session.captureForRemount({ - snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } }, - focused: true, - replacementKey: "draft:applied", - }); -} - -describe("composer editing remount handoff", () => { - it("keeps live text and selection when remounting from an initial draft publication", () => { +describe("composer editing session", () => { + it("publishes fullscreen changes once per transition", () => { const session = new ComposerEditingSession(); - leaveLiveDraft(session); - const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" }); - expect(handoff?.snapshot).toEqual({ - text: "Unpublished live draft", - selection: { start: 4, end: 17 }, - }); - expect(handoff?.focused).toBe(true); + const changes: boolean[] = []; + session.subscribe(() => changes.push(session.getFullscreen())); + session.setFullscreen(true); + session.setFullscreen(true); + session.setFullscreen(false); + expect(changes).toEqual([true, false]); }); - it("keeps local edits when the last applied replacement is still current", () => { + it("hands the retained caret and focus intent to exactly one later mount", () => { const session = new ComposerEditingSession(); - leaveLiveDraft(session); - const handoff = session.takeHandoff({ - kind: "replace", - key: "draft:applied", - text: "old replacement", - }); - expect(handoff?.snapshot).toEqual({ - text: "Unpublished live draft", - selection: { start: 4, end: 17 }, - }); + expect(session.takeEditor()).toBeNull(); + session.retainEditor({ selection: { start: 4, end: 17 }, focused: true }); + expect(session.takeEditor()).toEqual({ selection: { start: 4, end: 17 }, focused: true }); + expect(session.takeEditor()).toBeNull(); + }); +}); + +describe("composer editing sessions", () => { + const workspace = "server:workspace"; + const target = JSON.stringify({ kind: "agent", agentId: "a" }); + + it("keeps one session per tab and purpose until the tab target changes", () => { + const sessions = new ComposerEditingSessions(); + const chat = sessions.get(workspace, "tab-1", target, "chat"); + expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(chat); + expect(sessions.get(workspace, "tab-1", target, "terminal")).not.toBe(chat); + const retargeted = JSON.stringify({ kind: "agent", agentId: "b" }); + expect(sessions.get(workspace, "tab-1", retargeted, "chat")).not.toBe(chat); + }); + + it("reports a tab as fullscreen while any of its composers is fullscreen", () => { + const sessions = new ComposerEditingSessions(); + const notifications: boolean[] = []; + sessions.subscribe(() => notifications.push(sessions.isTabFullscreen(workspace, "tab-1"))); + const chat = sessions.get(workspace, "tab-1", target, "chat"); + sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true); + expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(false); + expect(sessions.isTabFullscreen(workspace, null)).toBe(false); + chat.setFullscreen(true); + expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(true); + chat.setFullscreen(false); + expect(notifications).toEqual([false, true, false]); + }); + + it("drops sessions for closed tabs and keeps retained ones", () => { + const sessions = new ComposerEditingSessions(); + const kept = sessions.get(workspace, "tab-1", target, "chat"); + kept.setFullscreen(true); + sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true); + sessions.retain(workspace, ["tab-1"]); + expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(kept); + expect(sessions.isTabFullscreen(workspace, "tab-2")).toBe(false); + expect(sessions.get(workspace, "tab-2", target, "chat").getFullscreen()).toBe(false); }); - - it.each(["New explicit draft", ""])( - "honors an explicit replacement made while the editor is absent: %j", - (text) => { - const session = new ComposerEditingSession(); - leaveLiveDraft(session); - const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text }); - expect(handoff?.snapshot).toEqual({ - text, - selection: { start: text.length, end: text.length }, - }); - expect(handoff?.focused).toBe(true); - expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull(); - }, - ); }); diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 97f7a79c51..6ad84d632d 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -1,5 +1,6 @@ import React, { createContext, + useCallback, useContext, useEffect, useMemo, @@ -7,62 +8,72 @@ import React, { useSyncExternalStore, type ReactNode, } from "react"; -import type { TextReplacement } from "@/composer/types"; import type { ComposerInputMode } from "@/composer/input-mode"; -export interface ComposerEditingHandoff { - snapshot: { text: string; selection: { start: number; end: number } }; - focused: boolean; - replacementKey: string; +export interface ComposerSelection { + start: number; + end: number; } -/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */ +/** What an unmounting editor leaves for the next mount of the same session. */ +export interface ComposerRetainedEditor { + selection: ComposerSelection; + /** The editor was focused, or meant to regain focus once its pane became active. */ + focused: boolean; +} + +/** + * Editing state that outlives the editor's mount. Draft text belongs to its draft + * owner; the editor keeps its own live buffer across layout modes. Switching between + * the compact and desktop screens remounts the editor, so the session carries the + * caret and focus intent across that remount while the draft owner carries the text. + */ export class ComposerEditingSession { private fullscreen = false; - private handoff: ComposerEditingHandoff | null = null; + private retained: ComposerRetainedEditor | null = null; private listeners = new Set<() => void>(); + constructor(private readonly onChange?: () => void) {} getFullscreen = () => this.fullscreen; + /** Called by the unmounting editor; the next mount of this session takes it. */ + retainEditor(retained: ComposerRetainedEditor) { + this.retained = retained; + } + takeEditor(): ComposerRetainedEditor | null { + const retained = this.retained; + this.retained = null; + return retained; + } subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; - changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) { - this.captureForRemount(handoff); + setFullscreen(fullscreen: boolean) { if (fullscreen === this.fullscreen) return; this.fullscreen = fullscreen; for (const listener of this.listeners) listener(); - } - captureForRemount(handoff: ComposerEditingHandoff) { - this.handoff = handoff; - } - peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null { - const handoff = this.handoff; - if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey) - return handoff; - // A replacement command issued since capture supersedes the old editor's live buffer. - return { - snapshot: { - text: replacement.text, - selection: { start: replacement.text.length, end: replacement.text.length }, - }, - focused: handoff.focused, - replacementKey: replacement.key, - }; - } - takeHandoff(replacement: TextReplacement) { - const handoff = this.peekHandoff(replacement); - this.handoff = null; - return handoff; + this.onChange?.(); } } -class ComposerEditingSessions { - private workspaces = new Map< - string, - Map }> - >(); +interface TabSessions { + targetKey: string; + sessions: Map; +} + +export class ComposerEditingSessions { + private workspaces = new Map>(); + private listeners = new Set<() => void>(); + private notify = () => { + for (const listener of this.listeners) listener(); + }; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) { let workspace = this.workspaces.get(workspaceKey); if (!workspace) { @@ -76,17 +87,31 @@ class ComposerEditingSessions { } let session = tab.sessions.get(purpose); if (!session) { - session = new ComposerEditingSession(); + session = new ComposerEditingSession(this.notify); tab.sessions.set(purpose, session); } return session; } + /** A tab is fullscreen when any of its composers is; the screen hides its chrome for it. */ + isTabFullscreen(workspaceKey: string, tabId: string | null): boolean { + if (tabId === null) return false; + const tab = this.workspaces.get(workspaceKey)?.get(tabId); + if (!tab) return false; + for (const session of tab.sessions.values()) if (session.getFullscreen()) return true; + return false; + } retain(workspaceKey: string, tabIds: readonly string[]) { const workspace = this.workspaces.get(workspaceKey); if (!workspace) return; const retained = new Set(tabIds); - for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId); + let changed = false; + for (const tabId of workspace.keys()) { + if (retained.has(tabId)) continue; + workspace.delete(tabId); + changed = true; + } if (workspace.size === 0) this.workspaces.delete(workspaceKey); + if (changed) this.notify(); } } @@ -140,3 +165,16 @@ export function useComposerEditingSession( export function useComposerFullscreen(session: ComposerEditingSession): boolean { return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen); } +/** Whether the composer of a workspace tab is fullscreen, for the chrome that tab sits under. */ +export function useComposerTabFullscreen(workspaceKey: string, tabId: string | null): boolean { + const sessions = useContext(SessionsContext); + const subscribe = useCallback( + (listener: () => void) => sessions?.subscribe(listener) ?? (() => {}), + [sessions], + ); + const read = useCallback( + () => sessions?.isTabFullscreen(workspaceKey, tabId) ?? false, + [sessions, workspaceKey, tabId], + ); + return useSyncExternalStore(subscribe, read, read); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c77b39d332..21bf7f6679 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,4 +1,8 @@ -import { useComposerEditingSession, useComposerFullscreen } from "./editing-session"; +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "./editing-session"; import type { ComposerTextSource } from "./text-source"; import { createStore, type StoreApi } from "zustand/vanilla"; import { useStore } from "zustand"; @@ -27,6 +31,7 @@ import { import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { usePanelStore } from "@/stores/panel-store"; import { useHasFinePointer } from "@/hooks/use-fine-pointer"; import { useShallow } from "zustand/shallow"; import { @@ -967,6 +972,12 @@ interface ComposerProps { submitLabel?: string; /** Overrides the mode's default placeholder, for text only the caller can build. */ placeholder?: string; + /** + * The host that also lays out the dock and chrome shares this session so its + * fullscreen state and the composer's agree. Defaults to the tab's session for + * `inputMode`. + */ + editingSession?: ComposerEditingSession; } const EMPTY_ARRAY: readonly QueuedMessage[] = []; @@ -1182,8 +1193,14 @@ function ComposerVoiceModeButton({ } export function Composer({ isPaneFocused, ...props }: ComposerProps) { + // A compact overlay panel (agent list, explorer) covers the pane without taking its + // focus. The composer gives up input from the moment the user asks for the panel, + // not when its motion settles, so the exit action and autocomplete never race it. + const isCompact = useIsCompactFormFactor(); + const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent"); + const coveredByOverlayPanel = isCompact && overlayPanelRequested; return ( - + @@ -1234,6 +1251,7 @@ function ComposerContentImpl({ inputWrapperStyle, isCompactLayout: isCompactLayoutOverride, inputMode = "chat", + editingSession: providedEditingSession, readOnly = false, submitLabel, placeholder, @@ -1339,7 +1357,7 @@ function ComposerContentImpl({ const nextPendingFileId = useRef(0); const isUploadingFile = pendingFiles.length > 0; const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); - const editingSession = useComposerEditingSession(inputMode); + const editingSession = useComposerEditingSession(inputMode, providedEditingSession); const isFullscreen = useComposerFullscreen(editingSession); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); @@ -2250,9 +2268,23 @@ function ComposerContentImpl({ [githubSearchItems, selectedAttachments, handleToggleGithubItem], ); + // Fullscreen fills the dock slot: the chain down to the input stretches and drops + // the inline padding, which the fullscreen presentation supplies itself. const inputAreaContainerStyle = useMemo( - () => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked], - [isComposerLocked], + () => [ + styles.inputAreaContainer, + isComposerLocked && styles.inputAreaLocked, + isFullscreen && styles.inputAreaFullscreen, + ], + [isComposerLocked, isFullscreen], + ); + const inputAreaContentStyle = useMemo( + () => [styles.inputAreaContent, isFullscreen && styles.fullscreenFill], + [isFullscreen], + ); + const messageInputContainerStyle = useMemo( + () => [styles.messageInputContainer, isFullscreen && styles.fullscreenFill], + [isFullscreen], ); const attachmentTray = useMemo( @@ -2363,15 +2395,19 @@ function ComposerContentImpl({ focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction} isMessageInputFocused={isMessageInputFocused} /> - + {/* Input area */} - - {queueList} + + {isFullscreen ? null : queueList} {!isFullscreen && sendErrorNode} - + ({ @@ -2496,6 +2538,18 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputAreaLocked: { opacity: 0.6, }, + inputAreaFullscreen: { + flex: 1, + minHeight: 0, + alignItems: "stretch", + paddingHorizontal: 0, + paddingBottom: 0, + }, + fullscreenFill: { + flex: 1, + minHeight: 0, + maxWidth: "100%", + }, inputAreaContent: { flexShrink: 1, width: "100%", diff --git a/packages/app/src/composer/input/fullscreen-exit.native.ts b/packages/app/src/composer/input/fullscreen-exit.native.ts new file mode 100644 index 0000000000..c2344d50b0 --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.native.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { BackHandler } from "react-native"; + +/** The hardware back button leaves fullscreen instead of the screen. */ +export function useComposerFullscreenExit( + active: boolean, + onExit: () => void, +): ((node: unknown) => void) | undefined { + useEffect(() => { + if (!active) return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + onExit(); + return true; + }); + return () => subscription.remove(); + }, [active, onExit]); + return undefined; +} diff --git a/packages/app/src/composer/input/fullscreen-exit.ts b/packages/app/src/composer/input/fullscreen-exit.ts new file mode 100644 index 0000000000..b76eeed5fc --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.ts @@ -0,0 +1,23 @@ +import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root"; + +/** + * Escape leaves fullscreen before app shortcuts see it. The registration only claims + * keys; focus stays where the user left it because the editor never remounts. + */ +export function useComposerFullscreenExit( + active: boolean, + onExit: () => void, +): ((node: unknown) => void) | undefined { + const layer = useOverlayLayer("modal"); + return useWebOverlayRegistration({ + active, + layer, + manageFocus: false, + onKeyDown: (event) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + onExit(); + return true; + }, + }); +} diff --git a/packages/app/src/composer/input/fullscreen-host.native.tsx b/packages/app/src/composer/input/fullscreen-host.native.tsx deleted file mode 100644 index 22ce0052bf..0000000000 --- a/packages/app/src/composer/input/fullscreen-host.native.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect, type ReactNode } from "react"; -import { Portal } from "@gorhom/portal"; -import { BackHandler, View, useWindowDimensions } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet } from "react-native-unistyles"; -import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { DEFAULT_FLOATING_PANEL_PORTAL_HOST } from "@/components/ui/floating-panel-portal"; -import { useKeyboardShift } from "@/keyboard/shift"; - -/** Same-window host keeps the IME attached while escaping the dock's clipping. */ -export function ComposerFullscreenHost({ - children, - onExit, - active, -}: { - children: ReactNode; - onExit: () => void; - active: boolean; -}) { - useEffect(() => { - if (!active) return; - const handler = BackHandler.addEventListener("hardwareBackPress", () => { - onExit(); - return true; - }); - return () => handler.remove(); - }, [active, onExit]); - const insets = useSafeAreaInsets(); - const { height } = useWindowDimensions(); - const { layoutShift } = useKeyboardShift(); - const frame = useAnimatedStyle(() => ({ - height: Math.max(0, height - layoutShift.value), - paddingTop: insets.top, - paddingBottom: insets.bottom, - paddingLeft: insets.left, - paddingRight: insets.right, - })); - return ( - - - {children} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - hidden: { display: "none" }, - background: { - position: "absolute", - top: 0, - bottom: 0, - left: 0, - right: 0, - backgroundColor: theme.colors.surface1, - }, -})); diff --git a/packages/app/src/composer/input/fullscreen-host.tsx b/packages/app/src/composer/input/fullscreen-host.tsx deleted file mode 100644 index ba634b268d..0000000000 --- a/packages/app/src/composer/input/fullscreen-host.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useMemo, useLayoutEffect, useState, type ReactNode } from "react"; -import { createPortal } from "react-dom"; -import { View } from "react-native"; -import { - getOverlayRoot, - useOverlayLayer, - OverlayLayerProvider, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; - -export function ComposerFullscreenHost({ - children, - onExit, - active, -}: { - children: ReactNode; - onExit: () => void; - active: boolean; -}) { - const layer = useOverlayLayer("modal"); - const registration = useWebOverlayRegistration({ - active, - layer, - onKeyDown: (event) => { - if (event.key !== "Escape") return false; - event.preventDefault(); - onExit(); - return true; - }, - }); - const [viewport, setViewport] = useState(readVisibleViewport); - useLayoutEffect(() => { - if (!active) return; - const update = () => setViewport(readVisibleViewport()); - const visualViewport = window.visualViewport; - update(); - visualViewport?.addEventListener("resize", update); - visualViewport?.addEventListener("scroll", update); - window.addEventListener("resize", update); - return () => { - visualViewport?.removeEventListener("resize", update); - visualViewport?.removeEventListener("scroll", update); - window.removeEventListener("resize", update); - }; - }, [active]); - const frameStyle = useMemo( - () => ({ - display: active ? ("flex" as const) : ("none" as const), - position: "absolute" as const, - top: viewport.top, - left: viewport.left, - width: viewport.width, - zIndex: layer, - pointerEvents: "auto" as const, - height: viewport.height, - }), - [active, layer, viewport], - ); - return createPortal( - - - {children} - - , - getOverlayRoot(), - ); -} - -function readVisibleViewport() { - const viewport = window.visualViewport; - return { - top: viewport?.offsetTop ?? 0, - left: viewport?.offsetLeft ?? 0, - width: viewport?.width ?? window.innerWidth, - height: viewport?.height ?? window.innerHeight, - }; -} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a0476ff7a7..a1b8c0fdbb 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -84,6 +84,7 @@ import { computeCanStartDictation, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -676,13 +677,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen ); } return ( - + {!isWeb && props.compact && !props.fullscreen ? ( ( const inputWrapperRef = useRef(null); const textInputRef = useRef(null); const isInputFocusedRef = useRef(false); - const handoff = editingSession.peekHandoff(textReplacement); - const focusIntentRef = useRef(Boolean(handoff?.focused)); - const valueRef = useRef(handoff?.snapshot.text ?? value); - const selectionRef = useRef( - handoff?.snapshot.selection ?? { start: value.length, end: value.length }, - ); + const focusIntentRef = useRef(false); + const valueRef = useRef(value); + const selectionRef = useRef({ start: value.length, end: value.length }); const activeComposerRef = useRef(isActiveComposer); activeComposerRef.current = isActiveComposer; const [webTextArea, setWebTextArea] = useState(null); const appliedTextReplacementKeyRef = useRef(textReplacement.key); - const textReplacementRef = useRef(textReplacement); - textReplacementRef.current = textReplacement; const webTextareaRef = useRef(null); const updateLiveTextPresence = useCallback((text: string) => { const nextHasLiveText = text.trim().length > 0; @@ -1289,43 +1276,49 @@ export const MessageInput = forwardRef( setHasLiveText(nextHasLiveText); }, []); - const assignTextInput = useCallback( - (input: ComposerTextInputHandle | null) => { - const previous = textInputRef.current; - if (!input && previous) { - editingSession.captureForRemount({ - snapshot: getComposerInputSnapshot( - previous, - valueRef.current, - selectionRef.current, - webTextareaRef.current, - ), - focused: previous.isFocused() || isInputFocusedRef.current, - replacementKey: appliedTextReplacementKeyRef.current, - }); - } - textInputRef.current = input; - if (isWeb) { - const element = getWebTextAreaImpl(input) as HTMLElement | null; - webTextareaRef.current = element; - setWebTextArea(element); - } - if (!input) return; - const transfer = editingSession.takeHandoff(textReplacementRef.current); - if (transfer) { - appliedTextReplacementKeyRef.current = textReplacementRef.current.key; - valueRef.current = transfer.snapshot.text; - updateLiveTextPresence(transfer.snapshot.text); - selectionRef.current = transfer.snapshot.selection; - input.replaceText(transfer.snapshot.text, transfer.snapshot.selection); - if (transfer.focused && activeComposerRef.current) input.focus(); - } - }, - [editingSession, updateLiveTextPresence], - ); + // One editor stays mounted through every layout mode, so this ref only follows + // the editor's own mount and unmount. + const assignTextInput = useCallback((input: ComposerTextInputHandle | null) => { + textInputRef.current = input; + if (!isWeb) return; + const element = getWebTextAreaImpl(input) as HTMLElement | null; + webTextareaRef.current = element; + setWebTextArea(element); + }, []); useLayoutEffect(() => { if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus(); }, [isActiveComposer]); + // The compact and desktop screens mount the editor in different trees, so a + // viewport that crosses the breakpoint remounts it. The draft owner brings the + // text back; the session brings the caret and focus intent back. On web the + // owner may still hold an unpublished edit while the new editor mounts, so the + // caret is placed now against the text on screen and again when the owner's + // hydrated text arrives. + const retainedSelectionRef = useRef<{ start: number; end: number } | null>(null); + useLayoutEffect(() => { + const retained = editingSession.takeEditor(); + if (retained) { + const text = textInputRef.current?.getText() ?? valueRef.current; + const selection = resolveRetainedSelection(retained.selection, text.length); + retainedSelectionRef.current = retained.selection; + selectionRef.current = selection; + textInputRef.current?.setSelection(selection); + if (retained.focused) { + focusIntentRef.current = true; + if (activeComposerRef.current) textInputRef.current?.focus(); + } + } + return () => { + editingSession.retainEditor({ + selection: getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ).selection, + focused: isInputFocusedRef.current || focusIntentRef.current, + }); + }; + }, [editingSession]); const getLiveText = useCallback(() => valueRef.current, []); const composerHeight = useComposerHeight({ getText: getLiveText, @@ -1340,29 +1333,15 @@ export const MessageInput = forwardRef( const updateComposerHeightForText = measuredComposerHeight?.onTextChange; const resetComposerHeight = measuredComposerHeight?.reset; - const changePresentation = useCallback( - (fullscreen: boolean) => { - if (editingSession.getFullscreen() === fullscreen) return; - const snapshot = getComposerInputSnapshot( - textInputRef.current, - valueRef.current, - selectionRef.current, - ); - editingSession.changePresentation(fullscreen, { - snapshot, - focused: textInputRef.current?.isFocused() ?? isInputFocusedRef.current, - replacementKey: appliedTextReplacementKeyRef.current, - }); - valueRef.current = snapshot.text; - selectionRef.current = snapshot.selection; - }, - [editingSession], - ); - const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]); + const exitFullscreen = useCallback(() => editingSession.setFullscreen(false), [editingSession]); + const enterFullscreen = useCallback(() => editingSession.setFullscreen(true), [editingSession]); + // The conversation sizes itself against the inline composer; fullscreen covers it. const handleComposerLayout = useCallback( - (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), - [onHeightChange], + (event: LayoutChangeEvent) => { + if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height); + }, + [isFullscreen, onHeightChange], ); const replaceText = useCallback( @@ -1427,8 +1406,15 @@ export const MessageInput = forwardRef( updateComposerHeightForText?.(valueRef.current, textReplacement.text); valueRef.current = textReplacement.text; updateLiveTextPresence(textReplacement.text); + const retainedSelection = + textReplacement.kind === "hydrated" ? retainedSelectionRef.current : null; + retainedSelectionRef.current = null; if (textReplacement.text === "") { textInputRef.current?.reset(); + } else if (retainedSelection) { + const selection = resolveRetainedSelection(retainedSelection, textReplacement.text.length); + selectionRef.current = selection; + textInputRef.current?.replaceText(textReplacement.text, selection); } else { textInputRef.current?.replaceText(textReplacement.text); } @@ -1696,17 +1682,6 @@ export const MessageInput = forwardRef( }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); - const getWebTextArea = useCallback( - (): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current), - [], - ); - - useLayoutEffect(() => { - if (isWeb) { - webTextareaRef.current = getWebTextArea() as HTMLElement | null; - } - }, [getWebTextArea, isFullscreen]); - usePasteImagesEffect({ textarea: webTextArea, isConnected, @@ -1832,6 +1807,8 @@ export const MessageInput = forwardRef( }, [onFocusChange]); const handleInputBlur = useCallback(() => { + // Fullscreen is nothing but the editor, so a blur there (a retained tab being + // hidden, the keyboard dismissed) keeps the intent: showing it again refocuses. if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); @@ -1888,8 +1865,9 @@ export const MessageInput = forwardRef( styles.textInput, mode.isMonospace && styles.textInputMonospace, isFullscreen ? styles.fullscreenTextInput : composerHeightStyle, + isCompact && !isFullscreen && styles.compactTextInput, ], - [composerHeightStyle, mode.isMonospace, isFullscreen], + [composerHeightStyle, mode.isMonospace, isFullscreen, isCompact], ); // Static content has no textarea to mirror, so it grows with its own text // instead of the measured input height. @@ -1897,13 +1875,22 @@ export const MessageInput = forwardRef( () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText], [mode.isMonospace], ); + // Fullscreen always shows Send; without content it is disabled, and looks it. + const isPresentedSendDisabled = resolvePresentationSendDisabled({ + fullscreen: isFullscreen, + disabled: isSendButtonDisabled, + hasText: hasLiveText, + attachments, + hasExternalContent, + allowEmpty: allowEmptySubmit, + }); const sendButtonCombinedStyle = useMemo( () => [ styles.sendButton, submitLabel ? styles.sendButtonLabeled : undefined, - isSendButtonDisabled && styles.buttonDisabled, + isPresentedSendDisabled && styles.buttonDisabled, ], - [isSendButtonDisabled, submitLabel], + [isPresentedSendDisabled, submitLabel], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], @@ -1942,14 +1929,7 @@ export const MessageInput = forwardRef( canPressLoadingButton={canPressLoadingButton} onSubmitLoadingPress={onSubmitLoadingPress} onDefaultSendAction={handleDefaultSendAction} - isSendButtonDisabled={resolvePresentationSendDisabled({ - fullscreen: isFullscreen, - disabled: isSendButtonDisabled, - hasText: hasLiveText, - attachments, - hasExternalContent, - allowEmpty: allowEmptySubmit, - })} + isSendButtonDisabled={isPresentedSendDisabled} submitAccessibilityLabel={submitAccessibilityLabel} sendButtonCombinedStyle={sendButtonCombinedStyle} isSubmitLoading={isSubmitLoading} @@ -1968,11 +1948,7 @@ export const MessageInput = forwardRef( canPressLoadingButton, onSubmitLoadingPress, handleDefaultSendAction, - isSendButtonDisabled, - hasLiveText, - attachments, - hasExternalContent, - allowEmptySubmit, + isPresentedSendDisabled, submitAccessibilityLabel, sendButtonCombinedStyle, isSubmitLoading, @@ -2008,9 +1984,7 @@ export const MessageInput = forwardRef( onBlur={handleInputBlur} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} scrollEnabled={isComposerScrollEnabled} - autoFocus={ - Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer - } + autoFocus={false} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} onPasteImages={onPasteImages} @@ -2029,8 +2003,6 @@ export const MessageInput = forwardRef( [ readOnly, textReplacement, - editingSession, - isActiveComposer, assignTextInput, textInputStyle, readOnlyTextStyle, @@ -2162,11 +2134,10 @@ export const MessageInput = forwardRef( handleRealtimeVoiceStop, ], ); - const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]); return ( @@ -2177,8 +2148,7 @@ export const MessageInput = forwardRef( /> ({ flexShrink: 1, position: "relative", }, + fullscreenContainer: { + flex: 1, + position: "relative", + }, inputWrapper: { flexShrink: 1, flexDirection: "column", @@ -2241,14 +2215,18 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputWrapperReadOnly: { borderStyle: "dotted", }, - compactTextSurface: { paddingRight: 36 }, + // The band the fullscreen glyph sits in is the input's own padding, so a tap + // anywhere in it (under the glyph, or where the glyph would be) is a tap on the + // editor: it focuses, shows the keyboard, and places the caret. Reserved whether + // or not the glyph shows, so text never reflows when it appears. + compactTextInput: { paddingRight: 36 }, fullscreenTextSurface: { flex: 1, minHeight: 0 }, fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" }, lineMeasurement: { position: "absolute", top: 0, left: 0, - right: 36, + right: 0, opacity: 0, maxHeight: undefined, minHeight: undefined, diff --git a/packages/app/src/composer/input/presentation.tsx b/packages/app/src/composer/input/presentation.tsx index d02821696a..64697999f5 100644 --- a/packages/app/src/composer/input/presentation.tsx +++ b/packages/app/src/composer/input/presentation.tsx @@ -3,19 +3,31 @@ import { View, type ViewProps } from "react-native"; import { Maximize2, Minimize2 } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock"; +import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { isWeb } from "@/constants/platform"; +import { ICON_SIZE } from "@/styles/theme"; import { StyleSheet } from "react-native-unistyles"; -import { ComposerFullscreenHost } from "./fullscreen-host"; +import { useComposerFullscreenExit } from "./fullscreen-exit"; +import { resolveGlyphRailOffset } from "./state"; + +const CONTROL_SIZE = 28; +const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md; function keepEditorFocused(event: import("react-native").PointerEvent) { if (isWeb) event.preventDefault(); } -/** Both presentations receive the same editing surface and send capability. */ +/** + * One tree for both layout modes. The editing surface keeps its position so the + * mounted editor, its native buffer, selection, and focus survive every toggle; + * only the chrome around it changes. Fullscreen shows exit top right and Send + * bottom right, both on the rail the inline Send button uses; a send error sits + * in the same footer as Send. + */ export function ComposerInputPresentation({ fullscreen, - iconSize, - active, + sendIconSize, surface, toolbar, send, @@ -30,8 +42,7 @@ export function ComposerInputPresentation({ overlay, }: { fullscreen: boolean; - iconSize: number; - active: boolean; + sendIconSize: number; surface: ReactNode; toolbar: ReactNode; send: ReactNode; @@ -46,55 +57,33 @@ export function ComposerInputPresentation({ overlay: ReactNode; }) { const { t } = useTranslation(); - // ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox. - // Compensate for that difference to align visible ink with Send, not just boxes. - const railAdjustment = iconSize / 12; + // A retained tab keeps its fullscreen editor mounted while hidden; the global exit + // action (Escape, hardware back) belongs only to the pane the user is in. + const { isActiveComposer } = useComposerKeyboardScope(); + const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit); + const railOffset = resolveGlyphRailOffset({ + controlSize: CONTROL_SIZE, + sendIconSize, + glyphIconSize: FULLSCREEN_GLYPH_SIZE, + }); const maximizeIcon = useCallback( - (color: string) => , - [iconSize], + (color: string) => , + [], ); const minimizeIcon = useCallback( - (color: string) => , - [iconSize], + (color: string) => , + [], ); - const enterStyle = useMemo( - () => [styles.fullscreenButton, { right: -6 + railAdjustment }], - [railAdjustment], - ); - const exitStyle = useMemo( - () => [styles.iconButton, { marginRight: railAdjustment }], - [railAdjustment], - ); - if (!fullscreen) - return ( - <> - - {attachmentSlot} - - {surface} - {showFullscreenButton ? ( - - ) : null} - - {toolbar} - - {overlay} - - ); + // The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge. + const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]); + const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]); return ( - - + + {fullscreen ? ( + ) : null} + + {fullscreen ? null : toolbar} + + + {fullscreen ? ( + + {error} + {send} + + ) : null} + {overlay} + ); } const styles = StyleSheet.create((theme) => ({ + inline: { flexShrink: 1, position: "relative" }, inlineTextSurface: { flexShrink: 1, position: "relative" }, iconButton: { - width: 28, - height: 28, + width: CONTROL_SIZE, + height: CONTROL_SIZE, padding: 0, borderWidth: 0, borderRadius: theme.borderRadius.full, }, - fullscreenButton: { + enterButton: { position: "absolute", top: 0, - width: 28, - height: 28, + width: CONTROL_SIZE, + height: CONTROL_SIZE, padding: 0, borderWidth: 0, borderRadius: theme.borderRadius.full, @@ -141,6 +162,10 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[4], gap: theme.spacing[3], }, + fullscreenInputWrapper: { flex: 1, minHeight: 0 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, exitRow: { alignItems: "flex-end", flexShrink: 0 }, - sendRow: { flexShrink: 0, alignItems: "flex-end" }, + // The send error rides the keyboard with Send; below the body it would sit behind it. + footer: { flexShrink: 0, gap: theme.spacing[3] }, + sendRow: { alignItems: "flex-end" }, })); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index a7f4113b28..6f2cdef74d 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -5,6 +5,8 @@ import { resolveActiveSendBehavior, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveGlyphRailOffset, + resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -333,10 +335,30 @@ describe("composer surface text", () => { ); }); - it("uses the live handoff when its replacement has already been applied", () => { + it("keeps the live buffer when the initial publication is already applied", () => { const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" }; - expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe( - "Live remount handoff", + expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe( + "Live edited draft", ); }); }); + +describe("retained selection", () => { + it("keeps a caret that fits and clamps one that no longer does", () => { + expect(resolveRetainedSelection({ start: 4, end: 17 }, 40)).toEqual({ start: 4, end: 17 }); + expect(resolveRetainedSelection({ start: 4, end: 17 }, 10)).toEqual({ start: 4, end: 10 }); + expect(resolveRetainedSelection({ start: 12, end: 17 }, 10)).toEqual({ start: 10, end: 10 }); + expect(resolveRetainedSelection({ start: -1, end: 3 }, 10)).toEqual({ start: 0, end: 3 }); + }); +}); + +describe("fullscreen glyph rail", () => { + it("nudges 16px fullscreen glyphs onto the Send arrow's ink edge", () => { + expect( + resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 16, glyphIconSize: 16 }), + ).toBeCloseTo(4 / 3); + expect( + resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 20, glyphIconSize: 16 }), + ).toBeCloseTo(1 / 6); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 3786613eb2..698965b5ac 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -193,6 +193,24 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise< await ctx.voice.stopVoice(); } +/** + * Offset that aligns a glyph's visible ink with the Send arrow's ink when both sit in + * equal controls on the same right rail. In Lucide's 24px viewBox ArrowUp ends at + * x19 while Maximize2/Minimize2 end at x21, so equal boxes leave the fullscreen + * glyphs visibly past the arrow. + */ +export function resolveGlyphRailOffset(input: { + controlSize: number; + sendIconSize: number; + glyphIconSize: number; +}): number { + const arrowInkInset = + (input.controlSize - input.sendIconSize) / 2 + (5 * input.sendIconSize) / 24; + const glyphInkInset = + (input.controlSize - input.glyphIconSize) / 2 + (3 * input.glyphIconSize) / 24; + return arrowInkInset - glyphInkInset; +} + export function resolveComposerSurfaceText( liveText: string, replacement: TextReplacement, @@ -200,3 +218,13 @@ export function resolveComposerSurfaceText( ): string { return replacement.key === appliedReplacementKey ? liveText : replacement.text; } + +/** A retained caret must fit the text the remounted editor shows. */ +export function resolveRetainedSelection( + retained: { start: number; end: number }, + textLength: number, +): { start: number; end: number } { + const end = Math.min(Math.max(retained.end, 0), textLength); + const start = Math.min(Math.max(retained.start, 0), end); + return { start, end }; +} diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index ee01144ed7..cb46412de0 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -9,8 +9,12 @@ export interface MessagePayload { forceSend?: boolean; } +/** + * Text the draft owner hands the editor. `hydrated` is the owner's answer to a mount: + * the draft's text as persisted or still pending from the editor that unmounted. + */ export interface TextReplacement { - kind: "initial" | "replace"; + kind: "initial" | "hydrated" | "replace"; key: string; text: string; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index bb51832aa0..f857bbb349 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -27,6 +27,11 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { RetainedChatContent } from "./retained-chat-content"; import { Composer } from "@/composer"; +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "@/composer/editing-session"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { resolveComposerTrackControlClearance, @@ -1155,6 +1160,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ const { t } = useTranslation(); // The workspace sheet is for looking; Open as tab brings the agent back with its composer. const showsComposer = usePaneContext().host !== "sheet"; + // The dock lays out fullscreen for the same session the composer edits in. + const composerEditingSession = useComposerEditingSession("chat"); + const isComposerFullscreen = useComposerFullscreen(composerEditingSession); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const tasks = useSessionStore((state): TodoEntry[] | undefined => state.sessions[serverId]?.agentTasks.get(agentId), @@ -1229,6 +1237,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ cwd={cwd} isSubmitLoading={false} agentInputDraft={agentInputDraft} + editingSession={composerEditingSession} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} onComposerHeightChange={handleComposerHeightChange} @@ -1292,7 +1301,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ); const dock = ( - + {dockContent} {composerSection} {dockOverlay} @@ -1323,13 +1332,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ function ChatSurface({ children, disabled, + fullscreen, }: { children: [ReactNode, ReactNode, ReactNode]; disabled: boolean; + fullscreen: boolean; }) { return ( - {children} + {children} ); } @@ -1473,6 +1484,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd, isSubmitLoading, agentInputDraft, + editingSession, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, @@ -1486,6 +1498,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; + editingSession: ComposerEditingSession; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; @@ -1509,6 +1522,7 @@ const AgentComposerSection = memo(function AgentComposerSection({ cwd={cwd} isSubmitLoading={isSubmitLoading} agentInputDraft={agentInputDraft} + editingSession={editingSession} onAttentionInputFocus={onAttentionInputFocus} onAttentionPromptSend={onAttentionPromptSend} onComposerHeightChange={onComposerHeightChange} @@ -1524,6 +1538,7 @@ function ActiveAgentComposer({ cwd, isSubmitLoading, agentInputDraft, + editingSession, onAttentionInputFocus, onAttentionPromptSend, onComposerHeightChange, @@ -1535,6 +1550,7 @@ function ActiveAgentComposer({ cwd: string; isSubmitLoading: boolean; agentInputDraft: AgentInputDraft; + editingSession: ComposerEditingSession; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; onComposerHeightChange: (height: number) => void; @@ -1631,6 +1647,7 @@ function ActiveAgentComposer({ onChangeAttachments={agentInputDraft.setAttachments} cwd={cwd} clearDraft={agentInputDraft.clear} + editingSession={editingSession} autoFocus autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)} isSubmitLoading={isSubmitLoading} @@ -1723,6 +1740,8 @@ const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", flexShrink: 1, + // Content-sized inline; fills the dock's fullscreen slot. + flexGrow: 1, }, }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 39c414d9e7..6d8fd3de5a 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -17,6 +17,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { ComposerDock } from "@/composer/dock"; +import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -1636,6 +1637,8 @@ export function NewWorkspaceScreen({ const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); + const composerEditing = useNewWorkspaceComposerEditing(); + const { leaveFullscreen } = composerEditing; const mergeWorkspaces = useCallback( (targetServerId: string, workspaces: Iterable) => { getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); @@ -2121,6 +2124,7 @@ export function NewWorkspaceScreen({ return; } outcome = "navigated"; + leaveFullscreen(); navigateToWorkspace({ serverId: targetServerId, workspaceId }); }, }); @@ -2171,6 +2175,7 @@ export function NewWorkspaceScreen({ forkDraftSetup, isStillOnCreateScreen, launchTarget, + leaveFullscreen, selectedServerId, supportsForgeSearch, t, @@ -2227,6 +2232,7 @@ export function NewWorkspaceScreen({ return; } outcome = "navigated"; + leaveFullscreen(); navigateToWorkspace({ serverId: targetServerId, workspaceId, target }); }, }); @@ -2250,6 +2256,7 @@ export function NewWorkspaceScreen({ t, terminalPromptText, toast, + leaveFullscreen, updateFormPreferences, withConnectedClient, ]); @@ -2369,10 +2376,12 @@ export function NewWorkspaceScreen({ const importSession = useImportSession({ serverId: selectedServerId }); + const isComposerFullscreen = composerEditing.isFullscreen(isTerminalLaunch); const composer = isTerminalLaunch ? ( - - - - - {composer} - {errorMessage ? {errorMessage} : null} - - + + {composer} + {errorMessage ? {errorMessage} : null} + {importSession.sheet} ); } +/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */ +function useNewWorkspaceComposerEditing() { + const terminal = useComposerEditingSession("terminal"); + const chat = useComposerEditingSession("chat"); + const isTerminalFullscreen = useComposerFullscreen(terminal); + const isChatFullscreen = useComposerFullscreen(chat); + // The sessions outlive the screen (its draft keeps them), so a launch that leaves + // the screen also leaves fullscreen; the next draft starts inline. + const leaveFullscreen = useCallback(() => { + terminal.setFullscreen(false); + chat.setFullscreen(false); + }, [chat, terminal]); + return { + terminal, + chat, + leaveFullscreen, + isFullscreen: (isTerminalLaunch: boolean) => + isTerminalLaunch ? isTerminalFullscreen : isChatFullscreen, + }; +} + function NewWorkspaceLayout({ isCompact, + fullscreen, title, formStack, onImportSession, children, }: { isCompact: boolean; + /** The composer covers the screen; the header leaves and the dock fills the content. */ + fullscreen: boolean; title: string; formStack: ReactNode; onImportSession: () => void; @@ -2472,13 +2503,19 @@ function NewWorkspaceLayout({ ); return ( - - {setupFields} - <> - {children} - {isCompact ? null : importSessionButton} - - + <> + {fullscreen ? null : } + + + + {setupFields} + <> + {children} + {isCompact || fullscreen ? null : importSessionButton} + + + + ); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 62c9ee4fa6..a8089b6112 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,7 @@ -import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session"; +import { + WorkspaceComposerEditingSessionsProvider, + useComposerTabFullscreen, +} from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; @@ -824,6 +827,16 @@ function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): bool return supportsStreamTopOverlay && activeTab?.kind === "agent"; } +/** A fullscreen composer covers the compact header; the column lays out without it. */ +function resolveCompactHeaderLayout(input: { + header: ReactNode; + activeTab: WorkspaceTabDescriptor | null; + composerFullscreen: boolean; +}): { header: ReactNode; floating: boolean } { + if (input.composerFullscreen) return { header: null, floating: false }; + return { header: input.header, floating: floatsHeaderOverContent(input.activeTab) }; +} + function shouldShowWorkspaceScreenHeader(input: { isFocusModeEnabled: boolean; isMobile: boolean; @@ -3095,7 +3108,6 @@ function WorkspaceScreenContent({ focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, - // The sheet owns input while it covers the tab. paneFocusSuspended, onFocusPane: handleFocusPane, buildMobilePaneContentModel, @@ -3528,6 +3540,11 @@ function WorkspaceScreenContent({ [uiTabs, sheetTab], ); const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`; + const compactHeaderLayout = resolveCompactHeaderLayout({ + header: compactWorkspaceHeader, + activeTab: activeTabDescriptor, + composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), + }); // The sheet is the column's last child, so it covers the compact header as a modal. const workspaceCenterColumn = isMobile ? ( @@ -3538,8 +3555,8 @@ function WorkspaceScreenContent({ > {(topOverlayInset) => ( From 6ad124758cb57698320e1287a4c27bcbad2652ae Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 06:30:17 +0700 Subject: [PATCH 03/17] Stack workspace sheets and tighten sidebar density (#6885) * Stack workspace sheets and tighten sidebar density - Workspace sheets stack: opening a target from a sheet covers it, Back and drag close only the top sheet, and the header is the sheet's drag handle. - The workspace sheet reuses the shared sheet handle, background and header. - Fix the shared sheet handle indicator rendering black. - Sidebar text returns to semantic sizes; Pinned and Workspaces share one section header. * Pause covered workspace sheets and mock the shared sheet handle in tests * Export BottomSheetHandle from the browser test Gorhom stub * Let a stacked sheet's backdrop catch taps on iOS * Bring a revealed sheet to the top instead of duplicating it as a tab --- docs/explorer-sidebar.md | 17 +- docs/glossary.md | 2 +- .../e2e/support/helpers/workspace-sheet.ts | 18 +- .../src/components/adaptive-modal-sheet.tsx | 49 ++- packages/app/src/components/left-sidebar.tsx | 51 +-- .../src/components/sidebar-workspace-list.tsx | 18 +- .../sidebar/pinned-section-header.tsx | 62 ---- .../sidebar/project-leading-visual.tsx | 9 +- .../sidebar/sidebar-group-toggle-row.tsx | 6 +- .../components/sidebar/sidebar-header-row.tsx | 4 +- .../sidebar/sidebar-section-header.tsx | 115 +++++++ .../sidebar/sidebar-status-list.tsx | 14 +- .../sidebar/sidebar-workspace-row-content.tsx | 11 +- .../ui/isolated-bottom-sheet-modal/index.tsx | 1 + .../screens/settings/plugins-page.test.tsx | 1 + .../screens/workspace/workspace-screen.tsx | 16 +- .../workspace/workspace-sheet-host.tsx | 321 +++++++++++------- .../src/stores/workspace-layout-store.test.ts | 201 ++++++++--- .../app/src/stores/workspace-layout-store.ts | 298 +++++++++------- packages/app/src/styles/density.ts | 12 +- .../src/workspace-tabs/open-beside.test.ts | 21 +- .../app/test-stubs/gorhom-bottom-sheet.ts | 1 + 22 files changed, 786 insertions(+), 462 deletions(-) delete mode 100644 packages/app/src/components/sidebar/pinned-section-header.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-section-header.tsx diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index abd95fc35f..9415abd85a 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -7,7 +7,7 @@ contracts. | ---------------- | ---------------------------- | ------------------------------------------ | | Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock | | Side pane | Ordinary workspace content | Created and closed like any workspace pane | -| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab | +| Workspace sheet | Compact look-ups | Stacks tabs; each closes or becomes a tab | ## Panel host contract @@ -28,12 +28,17 @@ retention, while each shell owns its tabs, focus, dragging, resizing, and shortc On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest (`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between -sheet and tab, so callers open targets the same way on every layout. The sheet holds one ordinary -`WorkspaceTab`, which is not persisted. +sheet and tab, so callers open targets the same way on every layout. Each sheet holds one ordinary +`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet +(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has +a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved +work. Opening a sheet as a tab dismisses the sheets above it, since the +tab would sit under them. The stack is not persisted. -The sheet is modal and covers the whole compact column, header included. It renders in the -workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs the navigator and -every workspace provider an ordinary pane has, and a portal drops them +Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag +handle, because the panel scrolls on its own and content panning would fight it. Sheets render in +the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need the navigator +and every workspace provider an ordinary pane has, and a portal drops them ([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)). A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it. diff --git a/docs/glossary.md b/docs/glossary.md index ac988e8d2b..dcf8178bc7 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -21,7 +21,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. - **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md). -- **Workspace sheet** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet). +- **Workspace sheet** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). diff --git a/packages/app/e2e/support/helpers/workspace-sheet.ts b/packages/app/e2e/support/helpers/workspace-sheet.ts index 8865073c81..b0544f7df6 100644 --- a/packages/app/e2e/support/helpers/workspace-sheet.ts +++ b/packages/app/e2e/support/helpers/workspace-sheet.ts @@ -7,8 +7,8 @@ export function workspaceSheet(page: Page, label: string): Locator { /** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */ const SHEET_SNAP_FRACTION = 0.92; -/** The dialog starts below the sheet's drag handle. */ -const SHEET_HANDLE_MAX_HEIGHT = 32; +/** The header starts below the sheet's drag indicator, inside the handle. */ +const SHEET_INDICATOR_MAX_HEIGHT = 32; /** * The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it @@ -18,14 +18,16 @@ export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Pro const viewportHeight = page.viewportSize()?.height ?? 0; const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION)); const sheet = workspaceSheet(page, label); + const header = sheet.getByTestId("workspace-sheet-header"); + const body = sheet.getByTestId("workspace-sheet-body"); await expect .poll(async () => { - const box = await sheet.boundingBox(); - if (!box) return null; - const top = Math.round(box.y); + const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]); + if (!headerBox || !bodyBox) return null; + const top = Math.round(headerBox.y); return { - dockedToBottom: Math.round(box.y + box.height) === viewportHeight, - topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT, + dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight, + topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT, }; }) .toEqual({ dockedToBottom: true, topAtSnap: true }); @@ -73,9 +75,11 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise< await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); } +/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */ export async function tapWorkspaceSheetBackdrop(page: Page): Promise { await page .getByRole("button", { name: "Bottom sheet backdrop" }) + .last() .click({ position: { x: 10, y: 10 } }); } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 707c0e8622..f7b4d4056c 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Keyboard, Pressable, Text, View } from "react-native"; import type { DimensionValue, StyleProp, ViewStyle } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, @@ -12,7 +12,7 @@ import { useGlobalWebOverlayLayer, useWebOverlayRegistration, } from "../lib/overlay-root"; -import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated from "react-native-reanimated"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { @@ -223,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({ const WEB_EXIT_DURATION_MS = 160; -function SheetBackground({ style }: BottomSheetBackgroundProps) { +/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */ +export function SheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); const combinedStyle = useMemo( () => [ @@ -239,6 +240,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { return ; } +const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({ + indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] }, +})); + +const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 }; + +/** + * The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children + * join the handle, so a header passed here drags the sheet like the indicator does. That is how a + * sheet whose content scrolls on its own keeps its header draggable without content panning. + */ +export function SheetHandle({ + children, + // Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator. + indicatorStyle: _indicatorStyle, + ...props +}: ComponentProps) { + if (!children) return ; + // The children carry their own semantics; the handle's slider role would swallow them on web. + return ( + + {children} + + ); +} + /** * The sheet body, indented to the sheet's content inset. * @@ -449,7 +483,6 @@ export function AdaptiveModalSheet({ bodyStyle, contextBridge = null, }: AdaptiveModalSheetProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); @@ -481,10 +514,6 @@ export function AdaptiveModalSheet({ ) : null, [footer, footerClearanceStyle, footerContainerStyle], ); - const handleIndicatorStyle = useMemo( - () => ({ backgroundColor: theme.colors.palette.zinc[600] }), - [theme.colors.palette.zinc], - ); useEffect(() => { if (!isWeb && visible) { // A newly opened sheet owns input. A keyboard belonging to the sheet below @@ -600,7 +629,7 @@ export function AdaptiveModalSheet({ backdropOpacity={0.45} enablePanDownToClose backgroundComponent={SheetBackground} - handleIndicatorStyle={handleIndicatorStyle} + handleComponent={SheetHandle} keyboardBehavior="extend" keyboardBlurBehavior="restore" accessible={false} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 087a3ffca8..f054668347 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -35,6 +35,7 @@ import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { DensityIcon } from "@/components/ui/density-icon"; +import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; @@ -805,21 +806,18 @@ function DesktopSidebar({ function WorkspacesSectionHeader() { return ( - - Workspaces - - - - - - - - - - - - - + + + + + + + + + + + + ); } @@ -850,29 +848,6 @@ const styles = StyleSheet.create((theme) => ({ sidebarHeaderGroupBelowChrome: { paddingTop: 0, }, - workspacesSectionHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[2], - // Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title - // lands at spacing[2] left to align with project icons. Settings2's painted path stops - // inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail. - paddingLeft: theme.spacing[2], - paddingRight: 4, - paddingTop: theme.spacing[1], - paddingBottom: theme.spacing[1], - }, - workspacesSectionTitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - workspacesSectionActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, sidebarContent: { flex: 1, minHeight: 0, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index bbda666436..c750121368 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,7 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { density, densityFontSize, densityIconBox } from "@/styles/density"; +import { density, densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -93,7 +93,7 @@ import { SidebarWorkspaceMenu, } from "@/components/sidebar/sidebar-workspace-menu"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; -import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; +import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { @@ -2114,6 +2114,7 @@ function ProjectModeList({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void; }) { + const { t } = useTranslation(); const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( @@ -2413,7 +2414,12 @@ function ProjectModeList({ <> {pinnedChats.length > 0 ? ( - + {pinnedCollapsed ? null : ( <> ({ }, newWorkspaceGhostText: { color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, minWidth: 0, flexShrink: 1, }, newWorkspaceGhostTextHovered: { - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, minWidth: 0, flexShrink: 1, color: theme.colors.foreground, @@ -2603,7 +2609,7 @@ const styles = StyleSheet.create((theme) => ({ }, projectTitle: { color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, fontWeight: "400", minWidth: 0, flexShrink: 1, diff --git a/packages/app/src/components/sidebar/pinned-section-header.tsx b/packages/app/src/components/sidebar/pinned-section-header.tsx deleted file mode 100644 index 2169492a08..0000000000 --- a/packages/app/src/components/sidebar/pinned-section-header.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { ChevronDown, ChevronRight } from "lucide-react-native"; -import { Pressable, Text } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { DensityIcon } from "@/components/ui/density-icon"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; -import { density, densityFontSize } from "@/styles/density"; - -export function PinnedSectionHeader({ - collapsed, - onToggle, -}: { - collapsed: boolean; - onToggle: () => void; -}) { - const { t } = useTranslation(); - const isCompact = useIsCompactFormFactor(); - const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); - - return ( - - {({ hovered }) => ( - <> - {t("sidebar.pinned.title")} - {hovered || isNative || isCompact ? ( - - ) : null} - - )} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - header: { - minHeight: density.rowHeight, - flexDirection: "row", - alignItems: "center", - alignSelf: "flex-start", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - userSelect: "none", - }, - title: { - color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "sm"), - fontWeight: theme.fontWeight.normal, - }, -})); diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx index 3f31181220..1830e3fa10 100644 --- a/packages/app/src/components/sidebar/project-leading-visual.tsx +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; -import { density, densityIconBox, densityIconProps } from "@/styles/density"; +import { densityIconBox, densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { @@ -24,6 +24,9 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; // Every surfaced status shares one badge shell, so the badge never changes size or position // between states. Only the thing inside it changes. +// Matches the workspace title's lineHeight (sidebar-workspace-row-content's +// workspaceBranchText) so the icon centers on the title rather than floating above it. +const LEADING_SLOT_HEIGHT = 20; const STATUS_BADGE_SIZE = 12; // How far the badge overhangs the icon's bottom-right corner. Well short of half the badge: // centered on the corner it reads as hanging off the icon rather than sitting on it, and the @@ -273,10 +276,10 @@ const styles = StyleSheet.create((theme) => { // The slot is as tall as the title's line box, not as tall as the icon, and centers the // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot // only as tall as the icon puts it above the title and the kebab — which is why the - // workspace status indicator shares the same `rowLineHeight`. + // workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`. projectLeadingVisualSlot: { width: densityIconBox("md"), - height: density.rowLineHeight, + height: LEADING_SLOT_HEIGHT, flexShrink: 0, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx index 3d437ae0ca..01e07eedd4 100644 --- a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx @@ -6,7 +6,7 @@ import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content"; import { DensityIcon } from "@/components/ui/density-icon"; -import { density, densityFontSize, densityIconBox } from "@/styles/density"; +import { density, densityIconBox } from "@/styles/density"; /** * The row that ends a truncated group. It is a workspace row that happens to say "Show more", so @@ -100,13 +100,13 @@ const styles = StyleSheet.create((theme) => ({ }, text: { color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, minWidth: 0, flexShrink: 1, }, textHovered: { color: theme.colors.foreground, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, minWidth: 0, flexShrink: 1, }, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 7001bf66a6..2eeded947d 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; import { DensityIcon } from "@/components/ui/density-icon"; import { Shortcut } from "@/components/ui/shortcut"; -import { density, densityFontSize, densityIconBox } from "@/styles/density"; +import { density, densityIconBox } from "@/styles/density"; import type { ShortcutKey } from "@/utils/format-shortcut"; type SidebarHeaderRowVariant = "header" | "compact" | "inline"; @@ -152,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({ iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") }, label: { flexShrink: 1, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, diff --git a/packages/app/src/components/sidebar/sidebar-section-header.tsx b/packages/app/src/components/sidebar/sidebar-section-header.tsx new file mode 100644 index 0000000000..7a959435bc --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-section-header.tsx @@ -0,0 +1,115 @@ +import { useMemo, type ReactNode } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { DensityIcon } from "@/components/ui/density-icon"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { density } from "@/styles/density"; + +/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle` + * collapses from its title; children sit at the trailing edge as its actions. */ +export function SidebarSectionHeader({ + title, + collapsed, + onToggle, + testID, + children, +}: { + title: string; + collapsed?: boolean; + onToggle?: () => void; + testID?: string; + children?: ReactNode; +}) { + return ( + + {onToggle ? ( + + ) : ( + + {title} + + )} + {children ? {children} : null} + + ); +} + +function CollapsibleTitle({ + title, + collapsed, + onToggle, + testID, +}: { + title: string; + collapsed: boolean; + onToggle: () => void; + testID?: string; +}) { + const isCompact = useIsCompactFormFactor(); + const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); + + return ( + + {({ hovered }) => ( + <> + {title} + {hovered || isNative || isCompact ? ( + + ) : null} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + minHeight: density.rowHeight, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + // The title lands at spacing[2] to align with the rows' leading icons. Trailing actions + // are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink + // rather than the box to the row rail. + paddingLeft: theme.spacing[2], + paddingRight: 4, + userSelect: "none", + }, + // The toggle reaches back over the header's leading padding so the whole label edge is pressable. + toggle: { + alignSelf: "stretch", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + marginLeft: -theme.spacing[2], + paddingHorizontal: theme.spacing[2], + }, + title: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 85c0d524f8..baffc3a010 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { StyleSheet } from "react-native-unistyles"; -import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { @@ -73,7 +73,7 @@ import { SidebarWorkspaceContextMenu, SidebarWorkspaceMenu, } from "@/components/sidebar/sidebar-workspace-menu"; -import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; +import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin"; @@ -146,6 +146,7 @@ export function SidebarStatusWorkspaceList({ parentGestureRef, dragGestureHostActive, }: StatusWorkspaceListProps) { + const { t } = useTranslation(); const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedWorkspaceGroupKeys, ); @@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({ <> {pinnedWorkspaces.length > 0 ? ( - + {pinnedCollapsed ? null : ( <> ({ }, statusGroupTitle: { color: theme.colors.foregroundMuted, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, fontWeight: "400", minWidth: 0, flexShrink: 1, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 33f14afcb5..7fc904f3b3 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -16,7 +16,7 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; import { useAppSettings } from "@/hooks/use-settings"; -import { density, densityFontSize, densityIconBox } from "@/styles/density"; +import { densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; @@ -453,6 +453,9 @@ export function SidebarWorkspaceTrailingActionOverlay({ ); } +// The status dot centres on the title's first line, so both share its line height. +const TITLE_LINE_HEIGHT = 20; + const styles = StyleSheet.create((theme) => ({ workspaceRowContent: { position: "relative", @@ -481,7 +484,7 @@ const styles = StyleSheet.create((theme) => ({ workspaceStatusDot: { position: "relative", width: densityIconBox("md"), - height: density.rowLineHeight, + height: TITLE_LINE_HEIGHT, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", @@ -513,9 +516,9 @@ const styles = StyleSheet.create((theme) => ({ // to the meta row, so it takes the full width the trailing slot leaves behind. workspaceBranchText: { color: theme.colors.foreground, - fontSize: densityFontSize(theme, "base"), + fontSize: theme.fontSize.base, fontWeight: "400", - lineHeight: density.rowLineHeight, + lineHeight: TITLE_LINE_HEIGHT, opacity: 0.76, flex: 1, minWidth: 0, diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 8dd55e5678..55e475b424 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -154,3 +154,4 @@ export function useIsolatedBottomSheetVisibility({ } export { SheetVisibleFrame } from "./visible-frame"; +export { systemBackPress } from "./back-press"; diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 0ede3f803b..8a502749db 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -80,6 +80,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { await vi.importActual("react-native"); return { BottomSheetBackdrop: () => null, + BottomSheetHandle: () => null, BottomSheetModal: ReactModule.forwardRef(() => null), BottomSheetScrollView: ScrollView, BottomSheetTextInput: TextInput, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a8089b6112..ef5b6aa779 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -78,6 +78,7 @@ import { selectExplorerSidebarPaneId, selectPaneFocusSuspended, type WorkspaceLayout, + type WorkspaceSheet, type WorkspaceTabPlacement, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, @@ -207,6 +208,7 @@ import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; +const NO_SHEETS: readonly WorkspaceSheet[] = []; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; @@ -1322,8 +1324,12 @@ function WorkspaceScreenContent({ const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const sheets = useWorkspaceLayoutStore((state) => + persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS, + ); + // Only the top sheet is on screen. const sheetTab = useWorkspaceLayoutStore((state) => - persistenceKey ? (state.sheetByWorkspace[persistenceKey]?.tab ?? null) : null, + persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null, ); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); @@ -2082,7 +2088,7 @@ function WorkspaceScreenContent({ const handleCloseTabById = useCallback( async (tabId: string) => { - if (persistenceKey && sheetTab?.tabId === tabId) { + if (persistenceKey && sheets.some((sheet) => sheet.tab.tabId === tabId)) { if (await confirmDiscardModifiedTab(tabId)) { closeWorkspaceTab(persistenceKey, tabId); } @@ -2113,7 +2119,7 @@ function WorkspaceScreenContent({ handleClosePassiveTab, handleCloseTerminalTab, persistenceKey, - sheetTab, + sheets, ], ); @@ -3536,8 +3542,8 @@ function WorkspaceScreenContent({ const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null; const workspacePanelTabIds = useMemo( - () => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])], - [uiTabs, sheetTab], + () => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)], + [uiTabs, sheets], ); const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`; const compactHeaderLayout = resolveCompactHeaderLayout({ diff --git a/packages/app/src/screens/workspace/workspace-sheet-host.tsx b/packages/app/src/screens/workspace/workspace-sheet-host.tsx index d44bdb6cc9..a46ea2c1c7 100644 --- a/packages/app/src/screens/workspace/workspace-sheet-host.tsx +++ b/packages/app/src/screens/workspace/workspace-sheet-host.tsx @@ -1,16 +1,31 @@ import BottomSheet, { BottomSheetBackdrop, type BottomSheetBackdropProps, + type BottomSheetHandleProps, } from "@gorhom/bottom-sheet"; import { useIsFocused } from "@react-navigation/native"; -import { type ComponentType, useCallback, useEffect, useMemo, useRef } from "react"; -import { BackHandler, View } from "react-native"; +import { + type ComponentType, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from "react"; +import { View } from "react-native"; import { useTranslation } from "react-i18next"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { SheetHeaderView } from "@/components/adaptive-modal-sheet"; +import { + SheetBackground, + SheetHandle, + SheetHeaderView, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { systemBackPress } from "@/components/ui/isolated-bottom-sheet-modal"; +import { RetainedPanelActivity } from "@/components/retained-panel"; import { Button } from "@/components/ui/button"; -import { isNative } from "@/constants/platform"; import type { PanelIconProps } from "@/panels/panel-registry"; import { getPanelRegistration } from "@/panels/panel-registry"; import { ensurePanelsRegistered } from "@/panels/register-panels"; @@ -22,17 +37,7 @@ import { import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store"; const SNAP_POINTS = ["92%"]; - -const ThemedBottomSheet = withUnistyles(BottomSheet, (theme) => ({ - backgroundStyle: { - backgroundColor: theme.colors.surface0, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], - }, - handleIndicatorStyle: { - backgroundColor: theme.colors.palette.zinc[600], - }, -})); +const NO_SHEETS: readonly WorkspaceSheet[] = []; function PanelIcon({ icon: Icon, @@ -46,6 +51,8 @@ const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ color: theme.colors.foregroundMuted, })); +// The workspace sheet is a Gorhom sheet rendered in place rather than a portaled modal, so the +// backdrop closes the sheet itself instead of dismissing the top of the modal stack. function renderBackdrop(props: BottomSheetBackdropProps) { return ( + ); +} + +interface WorkspaceSheetHandleValue { + header: SheetHeader; + onClose: () => void; +} + +const WorkspaceSheetHandleContext = createContext(null); + +// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and +// reads the header from context; a per-render function would remount the header on every change. +function WorkspaceSheetHandle(props: BottomSheetHandleProps) { + const value = useContext(WorkspaceSheetHandleContext); + if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet"); + return ( + + + + ); +} + export interface WorkspaceSheetHostProps { workspaceKey: string | null; serverId: string; @@ -73,96 +119,54 @@ interface WorkspaceSheetPresentationProps extends Omit< "workspaceKey" | "onCloseTab" > { sheet: WorkspaceSheet; - onClose: () => Promise; - onOpenAsTab: () => void; + /** Only the bottom sheet dims the workspace; the ones above share its dim. */ + isBottom: boolean; + /** Only the top sheet answers Android Back; the ones below wait their turn. */ + isTop: boolean; + onClose: (tabId: string) => Promise; + onOpenAsTab: (tabId: string) => void; } /** - * Hosts the workspace's sheet tab over the compact tab strip. It renders in the workspace tree, - * not through a portal, so the panel keeps every context an ordinary pane has. + * Hosts the workspace's sheets over the compact tab strip, one Gorhom sheet per entry, top last. + * They render in the workspace tree, not through a portal, so each panel keeps every context an + * ordinary pane has. The shared sheet parts (`SheetBackground`, `SheetHandle`, `SheetHeaderView`) + * keep them looking and dragging like every other sheet. */ export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) { - const sheet = useWorkspaceLayoutStore((state) => - workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null, + const sheets = useWorkspaceLayoutStore((state) => + workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, ); const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); - const sheetTabId = sheet?.tab.tabId ?? null; - const handleClose = useCallback(async () => { - if (sheetTabId) await onCloseTab(sheetTabId); - }, [onCloseTab, sheetTabId]); - const handleOpenAsTab = useCallback(() => { - if (workspaceKey) openSheetAsTab(workspaceKey); - }, [openSheetAsTab, workspaceKey]); - if (!sheet) return null; + const handleOpenAsTab = useCallback( + (tabId: string) => { + if (workspaceKey) openSheetAsTab(workspaceKey, tabId); + }, + [openSheetAsTab, workspaceKey], + ); + if (sheets.length === 0) return null; return ( - + + {sheets.map((sheet, index) => ( + + ))} + ); } function WorkspaceSheetPresentation({ sheet, - serverId, - workspaceId, - isWorkspaceFocused, - buildContent, - onClose, - onOpenAsTab, -}: WorkspaceSheetPresentationProps) { - const sheetRef = useRef(null); - const isScreenFocused = useIsFocused(); - const ownsBackPress = isNative && isWorkspaceFocused && isScreenFocused; - const handleChange = useCallback( - (index: number) => { - if (index !== -1) return; - // A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up. - void onClose().then(() => sheetRef.current?.snapToIndex(0)); - }, - [onClose], - ); - useEffect(() => { - // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back. - if (!ownsBackPress) return; - const subscription = BackHandler.addEventListener("hardwareBackPress", () => { - void onClose(); - return true; - }); - return () => subscription.remove(); - }, [onClose, ownsBackPress]); - - return ( - - - - - - ); -} - -function WorkspaceSheetBody({ - sheet, + isBottom, + isTop, serverId, workspaceId, isWorkspaceFocused, @@ -172,17 +176,110 @@ function WorkspaceSheetBody({ }: WorkspaceSheetPresentationProps) { const { t } = useTranslation(); const { tab } = sheet; + const sheetRef = useRef(null); + const isScreenFocused = useIsFocused(); ensurePanelsRegistered(); const registration = getPanelRegistration(tab.target.kind); if (!registration) { throw new Error(`No panel registration for kind: ${tab.target.kind}`); } - // The body is keyed by tab id and kind, so the registration and its descriptor hook stay fixed. const descriptor = registration.useDescriptor(tab.target as never, { serverId, workspaceId, tabId: tab.tabId, }); + + const handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]); + const handleChange = useCallback( + (index: number) => { + if (index !== -1) return; + // A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up. + void handleClose().then(() => sheetRef.current?.snapToIndex(0)); + }, + [handleClose], + ); + // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back. + const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; + useEffect(() => { + if (!ownsBackPress) return; + return systemBackPress.subscribe(() => { + void handleClose(); + return true; + }); + }, [handleClose, ownsBackPress]); + + const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], + ); + const handleHeaderClose = useCallback(() => { + void handleClose(); + }, [handleClose]); + const handleValue = useMemo( + () => ({ header, onClose: handleHeaderClose }), + [handleHeaderClose, header], + ); + + return ( + // Gorhom renders the handle beside the content, so the dialog wraps the whole sheet. + + {/* The header is the sheet's handle: the panel scrolls on its own, so dragging the content + would fight it, and the header is where people reach to pull the sheet down. */} + + + {/* A covered sheet keeps its state but stops its live work until it is on top again. */} + + + + + + + ); +} + +function WorkspaceSheetBody({ + tab, + isPaneFocused, + isWorkspaceFocused, + buildContent, +}: { + tab: WorkspaceSheet["tab"]; + /** Only the top sheet's pane is focused; the ones below are covered. */ + isPaneFocused: boolean; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; +}) { const tabDescriptor = useMemo( () => ({ key: tab.tabId, @@ -194,38 +291,20 @@ function WorkspaceSheetBody({ [tab], ); const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); - const handleClose = useCallback(() => { - void onClose(); - }, [onClose]); // The sheet reaches the bottom of the screen, so it owns the system navigation inset. const { bottom: bottomInset } = useSafeAreaInsets(); const contentStyle = useMemo( () => [styles.content, { paddingBottom: bottomInset }], [bottomInset], ); - const header = useMemo( - () => ({ - title: descriptor.label, - leading: , - actions: sheet.policy.openAsTab ? ( - - ) : undefined, - }), - [descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t], - ); return ( - - - - - + + ); } @@ -234,9 +313,11 @@ const styles = StyleSheet.create({ host: { ...StyleSheet.absoluteFillObject, }, - body: { - flex: 1, - minHeight: 0, + clearBackdrop: { + backgroundColor: "transparent", + }, + sheetLayer: { + ...StyleSheet.absoluteFillObject, }, content: { flex: 1, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 8c061d18b3..fc973f442d 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -4449,7 +4449,7 @@ describe("compact workspace sheet", () => { function createCompactStore() { const store = createWorkspaceLayoutStore(workspaceLayoutIds); - store.setState({ layoutByWorkspace: {}, sheetByWorkspace: {} }); + store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} }); store.getState().setLayoutMode("compact"); store.getState().openTab({ workspaceKey, @@ -4459,6 +4459,16 @@ describe("compact workspace sheet", () => { return store; } + function topSheet(store: ReturnType) { + return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1); + } + + function sheetTargets(store: ReturnType) { + return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map( + (sheet) => sheet.tab.target, + ); + } + function tabKinds(store: ReturnType): string[] { const layout = store.getState().layoutByWorkspace[workspaceKey]; const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab])); @@ -4523,7 +4533,7 @@ describe("compact workspace sheet", () => { const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab).toEqual({ + expect(topSheet(store)?.tab).toEqual({ tabId, target: fileTarget, createdAt: expect.any(Number), @@ -4547,22 +4557,71 @@ describe("compact workspace sheet", () => { const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); expect(tabId).toBe(fileTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId); }); - it("replaces the sheet content with the next sheet target", () => { + it("stacks the next sheet target over the open sheet", () => { const store = createCompactStore(); store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(tabKinds(store)).toEqual(["terminal"]); + + store.getState().closeTab(workspaceKey, nextTabId ?? ""); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("closes a lower sheet and keeps the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); const nextTarget = { kind: "file" as const, path: "src/other.ts" }; store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget); + store.getState().closeTab(workspaceKey, fileTabId ?? ""); + + expect(sheetTargets(store)).toEqual([nextTarget]); expect(tabKinds(store)).toEqual(["terminal"]); }); + it("brings a stacked target back to the top by dropping the sheets above it", () => { + const store = createCompactStore(); + const fileTabId = store + .getState() + .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + + expect(tabId).toBe(fileTabId); + expect(sheetTargets(store)).toEqual([fileTarget]); + }); + + it("opens the top sheet as a tab and dismisses the sheets that would cover it", () => { + const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + const nextTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); + + store.getState().openSheetAsTab(workspaceKey, nextTabId ?? ""); + + expect(sheetTargets(store)).toEqual([]); + expect(tabKinds(store)).toEqual(["terminal", "file"]); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(nextTabId); + }); + it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => { const store = createCompactStore(); store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); @@ -4572,7 +4631,7 @@ describe("compact workspace sheet", () => { target: { kind: "file", path: "src/background.ts" }, intent: "background", }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget); + expect(topSheet(store)?.tab.target).toEqual(fileTarget); expect(tabKinds(store)).toEqual(["terminal", "file"]); store.getState().openTab({ @@ -4580,7 +4639,7 @@ describe("compact workspace sheet", () => { target: { kind: "terminal", terminalId: "term-2" }, intent: "reveal", }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); }); it("moves the sheet tab into the focused pane with the same tab id and state", () => { @@ -4591,10 +4650,10 @@ describe("compact workspace sheet", () => { if (!sheetTabId) throw new Error("Expected a sheet tab"); store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 }); - const tabId = store.getState().openSheetAsTab(workspaceKey); + const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId); expect(tabId).toBe(sheetTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); const layout = store.getState().layoutByWorkspace[workspaceKey]; const pane = findPaneContainingTab(layout.root, sheetTabId); expect(pane?.id).toBe(layout.focusedPaneId); @@ -4616,7 +4675,7 @@ describe("compact workspace sheet", () => { expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore); store.getState().closeTab(workspaceKey, sheetTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); expect(tabKinds(store)).toEqual(["terminal"]); }); @@ -4639,19 +4698,21 @@ describe("compact workspace sheet", () => { store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); - expect(store.getState().sheetByWorkspace).toEqual({}); + expect(store.getState().sheetsByWorkspace).toEqual({}); expect(tabKinds(store)).toEqual(["terminal", "file"]); }); - it("keeps the sheet content as a tab when the layout widens", () => { + it("keeps every stacked sheet as a tab when the layout widens, focusing the top one", () => { const store = createCompactStore(); + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); const sheetTabId = store .getState() - .openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); store.getState().setLayoutMode("split-panes"); - expect(store.getState().sheetByWorkspace).toEqual({}); + expect(store.getState().sheetsByWorkspace).toEqual({}); + expect(tabKinds(store)).toEqual(["terminal", "file", "file"]); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId); }); @@ -4669,7 +4730,7 @@ describe("compact workspace sheet", () => { target: { kind: "agent", agentId: "child" }, intent: "reveal", }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({ + expect(topSheet(store)).toMatchObject({ tab: { target: { kind: "agent", agentId: "child" } }, policy: { openAsTab: true }, }); @@ -4679,7 +4740,7 @@ describe("compact workspace sheet", () => { target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" }, intent: "reveal", }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({ + expect(topSheet(store)).toMatchObject({ tab: { target: { kind: "provider_subagent", subagentId: "task-1" } }, policy: { openAsTab: false }, }); @@ -4689,7 +4750,7 @@ describe("compact workspace sheet", () => { .getState() .openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" }); } - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); }); @@ -4715,9 +4776,9 @@ describe("compact workspace sheet", () => { intent: "reveal", parentTabId: childSheetTabId ?? undefined, }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId); + expect(topSheet(store)?.parentTabId).toBe(rootTabId); - store.getState().openSheetAsTab(workspaceKey); + store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? ""); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId); }); @@ -4746,7 +4807,7 @@ describe("compact workspace sheet", () => { intent: "reveal", parentTabId: openChildSheet(), }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); let layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId); @@ -4756,7 +4817,7 @@ describe("compact workspace sheet", () => { intent: "reveal", parentTabId: openChildSheet(), }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId); expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId); @@ -4798,22 +4859,45 @@ describe("compact workspace sheet", () => { }); function sheetTabId(store: ReturnType): string | null { - return store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null; + return topSheet(store)?.tab.tabId ?? null; } - it("opens the next sheet target as a tab under a modified sheet", () => { + it("stacks the next sheet target over a modified sheet", () => { const store = createCompactStore(); - const modifiedSheetTabId = openModifiedFileSheet(store); + openModifiedFileSheet(store); const nextTarget = { kind: "file" as const, path: "src/other.ts" }; const nextTabId = store .getState() .openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); - expect(sheetTabId(store)).toBe(modifiedSheetTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.state).toEqual({ scrollTop: 40 }); - expect(stripTab(store, nextTabId ?? "")).toMatchObject({ target: nextTarget }); - expect(focusedTabId(store)).toBe(nextTabId); + expect(sheetTabId(store)).toBe(nextTabId); + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); + expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.state).toEqual({ + scrollTop: 40, + }); + }); + + it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => { + const store = createCompactStore(); + const otherTarget = { kind: "file" as const, path: "src/other.ts" }; + const otherTabId = store + .getState() + .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); + const modifiedSheetTabId = openModifiedFileSheet(store); + const focusedBefore = focusedTabId(store); + + const tabId = store + .getState() + .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); + + expect(tabId).toBe(otherTabId); + expect(sheetTargets(store)).toEqual([fileTarget, otherTarget]); + expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.tabId).toBe( + modifiedSheetTabId, + ); + expect(stripTab(store, tabId ?? "")).toBeNull(); + expect(focusedTabId(store)).toBe(focusedBefore); }); it("opens a tab target under a modified sheet without closing it", () => { @@ -4853,23 +4937,6 @@ describe("compact workspace sheet", () => { expect(tabKinds(store)).toEqual(["terminal", "terminal"]); }); - it("replaces an unmodified sheet with the next sheet target", () => { - const store = createCompactStore(); - store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); - - store.getState().openTab({ - workspaceKey, - target: { kind: "file", path: "src/other.ts" }, - intent: "reveal", - }); - - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual({ - kind: "file", - path: "src/other.ts", - }); - expect(tabKinds(store)).toEqual(["terminal"]); - }); - it("closes an unmodified sheet when another tab is chosen", () => { const store = createCompactStore(); const secondTabId = store.getState().openTab({ @@ -4913,10 +4980,38 @@ describe("compact workspace sheet", () => { }; store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined(); + expect(topSheet(store)).toBeDefined(); store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); + }); + + it("drops a departed agent's sheet from the middle of the stack", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const nextTarget = { kind: "file" as const, path: "src/other.ts" }; + store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" }); + store.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "child" }, + intent: "reveal", + }); + store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" }); + + store.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: false, + activeAgentIds: ["root"], + autoOpenAgentIds: [], + knownTerminalIds: ["term-1"], + standaloneTerminalIds: ["term-1"], + hasActivePendingTerminalCreate: false, + }); + + expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]); }); it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => { @@ -4941,7 +5036,7 @@ describe("compact workspace sheet", () => { }; store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] }); - expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId).toBe(sheetTabId); + expect(topSheet(store)?.tab.tabId).toBe(sheetTabId); seedAgents([ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, @@ -4949,7 +5044,7 @@ describe("compact workspace sheet", () => { ]); store.getState().reconcileTabs(workspaceKey, snapshot); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect( collectAllTabs(layout.root).filter( @@ -4975,7 +5070,7 @@ describe("compact workspace sheet", () => { .replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" }); expect(replacedTabId).toBe(sheetTabId); - expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({ + expect(topSheet(store)).toMatchObject({ tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } }, policy: { openAsTab: true }, }); @@ -4992,7 +5087,7 @@ describe("compact workspace sheet", () => { terminalId: "term-2", }); - expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined(); + expect(topSheet(store)).toBeUndefined(); expect(replacedTabId).toBe(sheetTabId); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId); @@ -5008,12 +5103,12 @@ describe("compact workspace sheet", () => { const received: string[][] = []; const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store); - store.getState().openTab({ + const sheetTabId = store.getState().openTab({ workspaceKey, target: { kind: "agent", agentId: "child" }, intent: "reveal", }); - store.getState().closeSheet(workspaceKey); + store.getState().closeTab(workspaceKey, sheetTabId ?? ""); stop(); expect(received).toEqual([[], ["child"], []]); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 226265860a..ba186b655a 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -126,8 +126,11 @@ export interface WorkspaceSheet { interface WorkspaceLayoutStore { /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; - /** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */ - sheetByWorkspace: Record; + /** + * Each workspace's open sheets, bottom to top; only the top one is on screen. A workspace with + * no sheet has no entry. Not persisted: a reload returns to the tab strip. + */ + sheetsByWorkspace: Record; layoutByWorkspace: Record; splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; @@ -148,9 +151,8 @@ interface WorkspaceLayoutStore { input: Pick, ) => boolean; setLayoutMode: (mode: WorkspaceLayoutMode) => void; - closeSheet: (workspaceKey: string) => void; - /** Moves the sheet's tab, keeping its tab id, into the focused pane. */ - openSheetAsTab: (workspaceKey: string) => string | null; + /** Moves a sheet's tab, keeping its tab id, into the focused pane. */ + openSheetAsTab: (workspaceKey: string, tabId: string) => string | null; /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ autoOpenPullRequestTab: ( workspaceKey: string, @@ -508,12 +510,12 @@ export function selectExplorerSidebarPaneId( /** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */ export function selectPaneFocusSuspended( - state: Pick, + state: Pick, workspaceKey: string, ): boolean { return ( Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) || - workspaceKey in state.sheetByWorkspace + workspaceKey in state.sheetsByWorkspace ); } @@ -682,15 +684,63 @@ function attachParentTab(input: { }); } -function withoutSheet( - state: Pick, +const NO_SHEETS: readonly WorkspaceSheet[] = []; + +function getSheets( + state: Pick, workspaceKey: string, -): Pick { - if (!(workspaceKey in state.sheetByWorkspace)) { - return { sheetByWorkspace: state.sheetByWorkspace }; +): readonly WorkspaceSheet[] { + return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS; +} + +function findSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): WorkspaceSheet | undefined { + return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId); +} + +function withSheets( + state: Pick, + workspaceKey: string, + sheets: readonly WorkspaceSheet[], +): Pick { + if (sheets.length > 0) { + return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } }; } - const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace; - return { sheetByWorkspace }; + if (!(workspaceKey in state.sheetsByWorkspace)) { + return { sheetsByWorkspace: state.sheetsByWorkspace }; + } + const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace; + return { sheetsByWorkspace }; +} + +function withoutSheet( + state: Pick, + workspaceKey: string, + tabId: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId), + ); +} + +function withSheetTab( + state: Pick, + workspaceKey: string, + tabId: string, + update: (sheet: WorkspaceSheet) => WorkspaceSheet, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).map((sheet) => + sheet.tab.tabId === tabId ? update(sheet) : sheet, + ), + ); } function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) { @@ -713,30 +763,16 @@ function resolveSheetPolicy( } /** - * The parent a newly opened tab links to. A target opened from inside the sheet names the sheet's - * tab, which the open replaces or closes, so it inherits the sheet's own parent instead. + * The parent a newly opened tab links to. A sheet tab is not in the layout, so a target opened + * from inside a sheet inherits that sheet's own parent instead. */ function resolveOpenParentTabId( - state: Pick, + state: Pick, workspaceKey: string, parentTabId: string | undefined, ): string | null { - const sheet = state.sheetByWorkspace[workspaceKey]; - if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId; - return parentTabId ?? null; -} - -/** A reveal of the resource the sheet already shows keeps the sheet's tab. */ -function sheetKeepsTabFor( - sheet: WorkspaceSheet | undefined, - target: WorkspaceTabTarget, - intent: WorkspaceTabOpenIntent, -): sheet is WorkspaceSheet { - return ( - intent === "reveal" && - sheet !== undefined && - panelResourceKey(sheet.tab.target) === panelResourceKey(target) - ); + if (!parentTabId) return null; + return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId; } function placeTargetInSheet(input: { @@ -766,12 +802,16 @@ function placeTargetInSheet(input: { }; } -/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */ +/** + * Moves one sheet's tab into the workspace's focused pane, keeping its tab id and state. The + * other sheets stay; callers that bring the tab to the front dismiss them. + */ function promoteSheetToTab( state: WorkspaceLayoutStore, workspaceKey: string, + tabId: string, ): { tabId: string; patch: Partial } | null { - const sheet = state.sheetByWorkspace[workspaceKey]; + const sheet = findSheet(state, workspaceKey, tabId); if (!sheet) return null; const { tab } = sheet; const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT); @@ -798,7 +838,7 @@ function promoteSheetToTab( return { tabId: result.tabId, patch: { - ...withoutSheet(state, workspaceKey), + ...withoutSheet(state, workspaceKey, tab.tabId), ...withoutFocusRestoration(state, workspaceKey), hiddenAgentIdsByWorkspace: tab.target.kind === "agent" @@ -817,37 +857,55 @@ function promoteSheetToTab( * A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the * panel and drop the work. Only the explicit, guarded close removes it. */ -function sheetHoldsUnsavedWork( - state: Pick, - workspaceKey: string, -): boolean { - const sheet = state.sheetByWorkspace[workspaceKey]; - return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId); +function sheetHoldsUnsavedWork(workspaceKey: string, sheet: WorkspaceSheet): boolean { + return isSheetTabModified(workspaceKey, sheet.tab.tabId); } /** - * Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the - * sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab. + * Where an open lands in the sheet stack, or null when it opens as a tab. A new target goes on + * top. A reveal of a resource a sheet already shows keeps that sheet's tab and brings it back to + * the top; the sheets above it close, except ones holding unsaved work, which stay beneath it. */ function resolveSheetPlacement( state: WorkspaceLayoutStore, workspaceKey: string, target: WorkspaceTabTarget, intent: WorkspaceTabOpenIntent, -): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null { +): { policy: CompactSheetPolicy; keptIndex: number | null } | null { const policy = resolveSheetPolicy(state, workspaceKey, target, intent); if (!policy) return null; - const sheet = state.sheetByWorkspace[workspaceKey]; - if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet }; - return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined }; + if (intent !== "reveal") return { policy, keptIndex: null }; + const sheets = getSheets(state, workspaceKey); + const resourceKey = panelResourceKey(target); + const index = sheets.findIndex((sheet) => panelResourceKey(sheet.tab.target) === resourceKey); + return { policy, keptIndex: index === -1 ? null : index }; } -/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */ -function dismissSheet( +/** Closes the sheets because navigation moved on underneath them, except ones with unsaved work. */ +function dismissSheets( + state: Pick, + workspaceKey: string, +): Pick { + return withSheets( + state, + workspaceKey, + getSheets(state, workspaceKey).filter((sheet) => sheetHoldsUnsavedWork(workspaceKey, sheet)), + ); +} + +/** Moves a sheet's tab to the front of the tab strip, dismissing the sheets that would cover it. */ +function openSheetTabInFront( state: WorkspaceLayoutStore, workspaceKey: string, -): Partial { - return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey); + tabId: string, +): { tabId: string; patch: Partial } | null { + const promoted = promoteSheetToTab(state, workspaceKey, tabId); + if (!promoted) return null; + const next = { ...state, ...promoted.patch }; + return { + tabId: promoted.tabId, + patch: { ...promoted.patch, ...dismissSheets(next, workspaceKey) }, + }; } /** @@ -857,10 +915,10 @@ function dismissSheet( */ function sheetAgentNoLongerFits( workspaceKey: string, - sheet: WorkspaceSheet | undefined, + sheet: WorkspaceSheet, snapshot: WorkspaceTabSnapshot, ): boolean { - if (sheet?.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; + if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true; return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null; } @@ -905,7 +963,7 @@ export function createWorkspaceLayoutStore( return tabId; }, layoutMode: "split-panes", - sheetByWorkspace: {}, + sheetsByWorkspace: {}, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, @@ -933,17 +991,26 @@ export function createWorkspaceLayoutStore( input.intent, ); if (sheetPlacement) { + const sheets = getSheets(get(), normalizedWorkspaceKey); + const { keptIndex } = sheetPlacement; const sheet = placeTargetInSheet({ - kept: sheetPlacement.kept, + kept: keptIndex === null ? undefined : sheets[keptIndex], target: normalizedTarget, intent: input.intent, policy: sheetPlacement.policy, parentTabId, state: input.state, }); - set((state) => ({ - sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet }, - })); + const below = + keptIndex === null + ? sheets + : [ + ...sheets.slice(0, keptIndex), + ...sheets + .slice(keptIndex + 1) + .filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)), + ]; + set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet])); return sheet.tab.tabId; } const placement = getOpenTabPlacement( @@ -990,7 +1057,7 @@ export function createWorkspaceLayoutStore( set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), // A tab brought to the front dismisses the sheet over it. - ...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)), + ...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)), hiddenAgentIdsByWorkspace: normalizedTarget.kind !== "agent" ? state.hiddenAgentIdsByWorkspace @@ -1032,24 +1099,25 @@ export function createWorkspaceLayoutStore( set((state) => { if (state.layoutMode === mode) return state; if (mode === "compact") return { layoutMode: mode }; - // Widening keeps whatever the user had open in the sheet as an ordinary tab. + // Widening keeps whatever the user had open in sheets as ordinary tabs. const next: WorkspaceLayoutStore = { ...state, layoutMode: mode }; - for (const workspaceKey of Object.keys(state.sheetByWorkspace)) { - const promoted = promoteSheetToTab(next, workspaceKey); - Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey)); + for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) { + for (const sheet of sheets) { + const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId); + Object.assign( + next, + promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId), + ); + } } return next; }); }, - closeSheet: (workspaceKey) => { + openSheetAsTab: (workspaceKey, tabId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) return; - set((state) => withoutSheet(state, normalizedWorkspaceKey)); - }, - openSheetAsTab: (workspaceKey) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) return null; - const promoted = promoteSheetToTab(get(), normalizedWorkspaceKey); + const normalizedTabId = trimNonEmpty(tabId); + if (!normalizedWorkspaceKey || !normalizedTabId) return null; + const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId); if (!promoted) return null; set(promoted.patch); return promoted.tabId; @@ -1173,8 +1241,8 @@ export function createWorkspaceLayoutStore( } set((state) => { - if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) { - return withoutSheet(state, normalizedWorkspaceKey); + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId); } const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey); const explorerSidebarPaneId = resolveExplorerSidebarPaneId( @@ -1283,8 +1351,8 @@ export function createWorkspaceLayoutStore( return { ...withoutFocusRestoration(state, normalizedWorkspaceKey), - // Choosing a tab dismisses the sheet that covers it. - ...dismissSheet(state, normalizedWorkspaceKey), + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1308,8 +1376,8 @@ export function createWorkspaceLayoutStore( }); return nextLayout ? { - // Choosing a tab dismisses the sheet that covers it. - ...dismissSheet(state, normalizedWorkspaceKey), + // Choosing a tab dismisses the sheets that cover it. + ...dismissSheets(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1323,29 +1391,27 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); const normalizedTarget = normalizeWorkspaceTabTarget(target); if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null; - const sheet = get().sheetByWorkspace[normalizedWorkspaceKey]; - if (sheet?.tab.tabId === normalizedTabId) { + if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) { const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget); - const replaced: WorkspaceSheet = { - ...sheet, - policy: policy ?? sheet.policy, - tab: { - ...sheet.tab, - target: normalizedTarget, - ...(tabState !== undefined ? { state: tabState } : {}), - }, - }; const next: WorkspaceLayoutStore = { ...get(), - sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced }, + ...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({ + ...sheet, + policy: policy ?? sheet.policy, + tab: { + ...sheet.tab, + target: normalizedTarget, + ...(tabState !== undefined ? { state: tabState } : {}), + }, + })), }; if (policy) { - set({ sheetByWorkspace: next.sheetByWorkspace }); + set({ sheetsByWorkspace: next.sheetsByWorkspace }); return normalizedTabId; } - // A target the sheet does not host becomes the tab it would have opened as. - const promoted = promoteSheetToTab(next, normalizedWorkspaceKey); - set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey)); + // A target sheets do not host becomes the tab it would have opened as. + const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId); + set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId)); return promoted?.tabId ?? null; } const result = replaceTabTargetInLayout({ @@ -1378,18 +1444,14 @@ export function createWorkspaceLayoutStore( const normalizedTabId = trimNonEmpty(tabId); if (!normalizedWorkspaceKey || !normalizedTabId) return; set((state) => { - const sheet = state.sheetByWorkspace[normalizedWorkspaceKey]; - if (sheet?.tab.tabId === normalizedTabId) { - const { state: _previous, ...tab } = sheet.tab; - return { - sheetByWorkspace: { - ...state.sheetByWorkspace, - [normalizedWorkspaceKey]: { - ...sheet, - tab: tabState === undefined ? tab : { ...tab, state: tabState }, - }, - }, - }; + if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) { + return withSheetTab(state, normalizedWorkspaceKey, normalizedTabId, (sheet) => { + const { state: _previous, ...tab } = sheet.tab; + return { + ...sheet, + tab: tabState === undefined ? tab : { ...tab, state: tabState }, + }; + }); } const layout = setTabStateInLayout({ layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey), @@ -1485,11 +1547,11 @@ export function createWorkspaceLayoutStore( ); } } - const sheetAgentGone = sheetAgentNoLongerFits( - normalizedWorkspaceKey, - state.sheetByWorkspace[normalizedWorkspaceKey], - snapshot, + const sheets = getSheets(state, normalizedWorkspaceKey); + const fittingSheets = sheets.filter( + (sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot), ); + const sheetAgentGone = fittingSheets.length !== sheets.length; if ( state.layoutByWorkspace[normalizedWorkspaceKey] && nextLayout === rawLayout && @@ -1500,7 +1562,7 @@ export function createWorkspaceLayoutStore( } return { - ...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}), + ...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}), pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, @@ -2006,7 +2068,7 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || normalizedWorkspaceKey in state.sidePaneIdByWorkspace || normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace || - normalizedWorkspaceKey in state.sheetByWorkspace; + normalizedWorkspaceKey in state.sheetsByWorkspace; if (!hasAny) { return state; } @@ -2035,7 +2097,7 @@ export function createWorkspaceLayoutStore( const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } = state.sidePaneIdByWorkspace; return { - ...withoutSheet(state, normalizedWorkspaceKey), + ...withSheets(state, normalizedWorkspaceKey, NO_SHEETS), pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, @@ -2152,14 +2214,16 @@ export function observeOpenWorkspaceAgentIds( store = useWorkspaceLayoutStore, ): () => void { let previous: string[] | undefined; - const publish = (state: Pick) => { + const publish = ( + state: Pick, + ) => { const ids = new Set(); const tabsByWorkspace = [ ...Object.entries(state.layoutByWorkspace).map( ([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const, ), - ...Object.entries(state.sheetByWorkspace).map( - ([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const, + ...Object.entries(state.sheetsByWorkspace).map( + ([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const, ), ]; for (const [workspaceKey, tabs] of tabsByWorkspace) { @@ -2177,7 +2241,7 @@ export function observeOpenWorkspaceAgentIds( const unsubscribe = store.subscribe((state, before) => { if ( state.layoutByWorkspace !== before.layoutByWorkspace || - state.sheetByWorkspace !== before.sheetByWorkspace + state.sheetsByWorkspace !== before.sheetsByWorkspace ) { publish(state); } diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts index 5b452bcb93..2383685d24 100644 --- a/packages/app/src/styles/density.ts +++ b/packages/app/src/styles/density.ts @@ -2,7 +2,7 @@ import { isCompactBreakpoint } from "@/constants/layout"; import { ICON_SIZE, type Theme } from "@/styles/theme"; /** - * Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact + * Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact * layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay * desktop-sized everywhere, so a surface migrates by swapping its sizes for these. * @@ -11,10 +11,8 @@ import { ICON_SIZE, type Theme } from "@/styles/theme"; */ export type DensityIconSize = keyof typeof ICON_SIZE; -type TextSize = "sm" | "base"; const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; -const COMPACT_TEXT_SIZE: Record = { sm: "base", base: "lg" }; // Compact layouts end where `md` begins; see `isCompactBreakpoint`. function byLayout(compact: T, desktop: T) { @@ -25,9 +23,7 @@ export const density = { /** A list row such as a sidebar entry. */ rowHeight: byLayout(44, 36), /** A row in a tight group, such as the sidebar's navigation rows. */ - tightRowHeight: byLayout(40, 28), - /** The line height of a row's `base` text; slots that align to its first line share it. */ - rowLineHeight: byLayout(22, 20), + tightRowHeight: byLayout(36, 28), /** A square icon-only button that sits in a row or toolbar. */ iconButton: byLayout(44, 28), }; @@ -37,10 +33,6 @@ export function densityIconBox(size: DensityIconSize) { return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]); } -export function densityFontSize(theme: Theme, size: TextSize) { - return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]); -} - export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number { return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size]; } diff --git a/packages/app/src/workspace-tabs/open-beside.test.ts b/packages/app/src/workspace-tabs/open-beside.test.ts index ac181eeb75..9e80745db8 100644 --- a/packages/app/src/workspace-tabs/open-beside.test.ts +++ b/packages/app/src/workspace-tabs/open-beside.test.ts @@ -27,7 +27,7 @@ const WORKSPACE_KEY = "server-1:workspace-1"; beforeEach(() => { useWorkspaceLayoutStore.setState({ layoutMode: "split-panes", - sheetByWorkspace: {}, + sheetsByWorkspace: {}, layoutByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, sidePaneIdByWorkspace: {}, @@ -56,7 +56,7 @@ describe("openPreferredWorkspacePreview", () => { }); const state = useWorkspaceLayoutStore.getState(); - expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({ + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.at(-1)?.tab).toMatchObject({ tabId, target: { kind: "file", path: "src/preview.ts" }, }); @@ -96,7 +96,7 @@ describe("previews under a sheet with unsaved work", () => { }); } - it("reuses one preview tab instead of adding a tab per link", () => { + it("stacks link previews over the sheet instead of dropping its unsaved work", () => { const store = useWorkspaceLayoutStore.getState(); store.openTab({ workspaceKey: WORKSPACE_KEY, @@ -114,18 +114,17 @@ describe("previews under a sheet with unsaved work", () => { { modified: true }, ); - const firstPreviewTabId = previewFile("src/first.ts"); - const secondPreviewTabId = previewFile("src/second.ts"); + previewFile("src/first.ts"); + previewFile("src/second.ts"); const state = useWorkspaceLayoutStore.getState(); - expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget); - expect(secondPreviewTabId).toBe(firstPreviewTabId); - const root = state.layoutByWorkspace[WORKSPACE_KEY].root; - const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target])); - expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([ - { kind: "terminal", terminalId: "term-1" }, + expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.map((sheet) => sheet.tab.target)).toEqual([ + sheetTarget, + { kind: "file", path: "src/first.ts" }, { kind: "file", path: "src/second.ts" }, ]); + const root = state.layoutByWorkspace[WORKSPACE_KEY].root; + expect(findPaneById(root, "main")?.tabIds).toHaveLength(1); }); }); diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 784a7569ff..5f3e8c7e8f 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -8,6 +8,7 @@ export default Stub; export const BottomSheetModal = Stub; export const BottomSheetModalProvider = PassThrough; export const BottomSheetBackdrop = Stub; +export const BottomSheetHandle = Stub; export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; From 73221ce5bd25b5f003efcb041a0d107d3c766805 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:14:42 +0200 Subject: [PATCH 04/17] fix(app): render local Markdown images with encoded paths (#6877) Decode assistant Markdown image destinations before resolving a filesystem read. Keep plain filesystem paths and direct image URIs unchanged so Markdown Preview and literal percent filenames are decoded only once. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/agent-tab-image-stability.spec.ts | 37 ++++++++++++ .../e2e/support/helpers/assistant-images.ts | 3 +- packages/app/src/attachments/utils.test.ts | 12 ++++ packages/app/src/attachments/utils.ts | 11 +++- packages/app/src/components/message.tsx | 3 +- .../assistant-markdown-image-source.test.ts | 58 +++++++++++++++++++ 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/utils/assistant-markdown-image-source.test.ts diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 5f70bdafe3..841314343f 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -16,6 +16,7 @@ import { } from "../support/helpers/assistant-images"; import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import path from "node:path"; const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ imageWorkspace: async ({ page: _page }, provide) => { @@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ }, }); +test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(120_000); + const image = await createSmallAssistantPng(workspace, { + alt: "Angle destination image", + fileName: "My image Мои.png", + }); + const agent = await createSettledMockAgent(workspace, "Encoded image paths"); + await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`); + await openAssistantImageTimeline(page, agent); + await expectAssistantImageRendered(page, image); + + const encodedImage = { ...image, alt: "Encoded destination image" }; + await emitSettledAssistantImage( + workspace.client, + agent, + encodedImage, + encodeURI(path.join(workspace.repoPath, image.relativePath)), + ); + await expectAssistantImageRendered(page, encodedImage); + + const literalPercentImage = await createSmallAssistantPng(workspace, { + alt: "Literal percent filename image", + fileName: "literal%20name.png", + }); + await emitSettledAssistantImage( + workspace.client, + agent, + literalPercentImage, + encodeURI(literalPercentImage.relativePath), + ); + await expectAssistantImageRendered(page, literalPercentImage); +}); + test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({ imageWorkspace: workspace, page, diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts index 63bc182eaf..c4200c7c75 100644 --- a/packages/app/e2e/support/helpers/assistant-images.ts +++ b/packages/app/e2e/support/helpers/assistant-images.ts @@ -87,10 +87,11 @@ export async function emitSettledAssistantImage( client: SeedDaemonClient, agent: ArchiveTabAgent, image: AssistantImageFixture, + destination: string = image.relativePath, ): Promise { await client.sendAgentMessage( agent.id, - `Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`, + `Emit settled assistant image Markdown: ![${image.alt}](${destination})`, ); const result = await client.waitForFinish(agent.id, 30_000); if (result.status !== "idle" || result.final?.lastError) { diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index d6fce078f2..9bf982c6e8 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -42,6 +42,18 @@ describe("fileUriToPath", () => { }); describe("localFileSourceToPath", () => { + it.each([ + "https://example.com/image%20name.png", + "data:image/png;base64,abc%20", + "blob:https://example.com/image%20name", + ])("preserves a direct Markdown image URI: %s", (source) => { + expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source); + }); + + it("preserves malformed percent escapes rather than throwing", () => { + expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png"); + }); + it("decodes markdown-encoded Windows drive-letter paths", () => { expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt"); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index 5fa0334359..46483bf0e7 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string { return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } -export function localFileSourceToPath(source: string): string { +export function localFileSourceToPath( + source: string, + // Markdown destinations are URI-encoded; plain filesystem paths are not. + { decodeHref = false }: { decodeHref?: boolean } = {}, +): string { + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } let path = source; if (source.startsWith("file://")) { path = fileUriToPath(source); - } else if (isMarkdownEncodedWindowsDrivePath(source)) { + } else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) { path = decodeFilePathSource(source); } return normalizeWindowsDrivePath(path); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4587556ebe..7915832470 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -95,6 +95,7 @@ import { } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { localFileSourceToPath } from "@/attachments/utils"; import { AttachmentFrame, AttachmentLabel, @@ -1945,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( { + let workspaceRoot: string; + const imageBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ); + + beforeEach(async () => { + workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-")); + await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes); + await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it.each([ + "absolute angle", + "absolute encoded", + "relative angle", + "relative encoded", + "file URI", + "literal percent", + ])("reads the actual image for the %s destination", async (form) => { + const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png"); + const destinations: Record = { + "absolute angle": `<${absolute}>`, + "absolute encoded": encodeURI(absolute), + "relative angle": "", + "relative encoded": encodeURI("My Apps/Мои/test.png"), + "file URI": `file://${encodeURI(absolute)}`, + "literal percent": `${workspaceRoot}/literal%2520name.png`, + }; + const tokens = createAssistantMarkdownParser().parseInline( + `![test](${destinations[form]})`, + {}, + ); + const image = tokens + .flatMap((token) => token.children ?? []) + .find((token) => token.type === "image"); + expect(image).toBeDefined(); + const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true }); + const target = resolveAssistantImageSource({ source, workspaceRoot }); + expect(target?.kind).toBe("file_rpc"); + if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target"); + await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes); + }); +}); From 6db47b3c37f702b8647a6c8667b685bfe74091f0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 08:59:49 +0700 Subject: [PATCH 05/17] fix(app): keep the keyboard closed after mobile sidebar dismissal (#6945) Separate composer keyboard eligibility from pane focus restoration. A sidebar dismisses the keyboard without hiding the pane, so its close must not restore editing focus. --- docs/floating-panels.md | 9 +- .../app/e2e/browser/composer-focus.spec.ts | 90 +++++++++++++++++++ .../e2e/browser/composer-whitespace.spec.ts | 12 +-- packages/app/e2e/support/helpers/composer.ts | 6 ++ packages/app/e2e/support/helpers/sidebar.ts | 38 ++++++++ packages/app/src/composer/index.tsx | 7 +- packages/app/src/composer/input/input.tsx | 26 +++--- packages/app/src/composer/keyboard-scope.tsx | 19 +++- 8 files changed, 181 insertions(+), 26 deletions(-) diff --git a/docs/floating-panels.md b/docs/floating-panels.md index cb51c8f7dd..9241cec56d 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -217,7 +217,14 @@ sheet suspends pane focus, and the composer gives up input from the moment the user asks for the compact agent list or explorer, before the panel's motion settles. Pane focus itself stays put under those panels: the agent panel reads it as "the user is in this chat" for attention clearing, and opening the agent -list must not clear the attention it shows. +list must not clear the attention it shows. Focus restoration follows pane +focus, not the active composer. The editor keeps its focus intent only across +a blur that lands while its pane is not the user's (a retained tab hidden, a +sheet over it) and refocuses when the pane comes back. A blur while the pane is +theirs drops the intent in every layout mode, and the keyboard the compact +panels dismiss when they settle is such a blur, so closing the agent list or +explorer never reopens the keyboard. Keying restoration on the active composer +made that dismiss look like a hidden-tab blur and refocused on close. The band beside the inline editor where the fullscreen glyph sits is the input's own right padding, so a tap anywhere in it focuses the editor whether or not the glyph shows. On web the slot is a fixed box over the visible viewport, which is diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index b79cfd9255..cbc710a94a 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,13 +1,30 @@ import { expect, test, type Page } from "../support/fixtures"; import { + composerLocator, + blurComposerInput, + enterComposerFullscreen, + exitComposerFullscreen, expectComposerDraft, expectComposerFocused, + expectComposerFullscreenControls, expectComposerNotFocused, expectComposerVisible, + expectWorkspaceHeaderVisible, + resizeComposerViewport, submitMessage, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + closeMobileAgentSidebar, + closeMobileExplorer, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + expectMobileExplorerHidden, + expectMobileExplorerVisible, + swipeOpenMobileAgentSidebar, + swipeOpenMobileExplorer, +} from "../support/helpers/sidebar"; test("submitting a message leaves the composer ready for the next message", async ({ page }) => { const agent = await seedMockAgentWorkspace({ @@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => { } }); }); + +const COMPACT_PANELS = [ + { + name: "agent list", + open: swipeOpenMobileAgentSidebar, + expectOpen: expectMobileAgentSidebarVisible, + close: closeMobileAgentSidebar, + expectClosed: expectMobileAgentSidebarHidden, + }, + { + name: "Explorer", + open: swipeOpenMobileExplorer, + expectOpen: expectMobileExplorerVisible, + close: closeMobileExplorer, + expectClosed: expectMobileExplorerHidden, + }, +] as const; + +for (const panel of COMPACT_PANELS) { + test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-panel-", + title: `Composer focus under ${panel.name}`, + }); + const draft = "Line one\nLine two\nLine three"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + + await test.step("an untouched editor stays untouched", async () => { + await expectComposerNotFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + }); + + await test.step("an inline editor blurred under the panel stays blurred", async () => { + await composerLocator(page).fill(draft); + await composerLocator(page).click(); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerDraft(page, draft); + }); + + await test.step("a fullscreen editor blurred under the panel stays blurred", async () => { + await composerLocator(page).click(); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerFullscreenControls(page); + await expectComposerDraft(page, draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + }); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 0c74fc7630..ca8704ed25 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -37,6 +37,7 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + swipeOpenMobileAgentSidebar, } from "../support/helpers/sidebar"; async function composerHeight(page: Page): Promise { @@ -165,17 +166,6 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } } }); -async function swipeOpenMobileAgentSidebar(page: Page): Promise { - const viewport = page.viewportSize(); - if (!viewport) throw new Error("viewport size is required"); - const y = Math.round(viewport.height / 2); - await page.mouse.move(8, y); - await page.mouse.down(); - await page.mouse.move(40, y, { steps: 4 }); - await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); - await page.mouse.up(); -} - /** Freeze the page's timers and animation frames until `page.clock.resume()`. */ async function pausePageClock(page: Page): Promise { const now = Date.now(); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index efd9266657..d4ef8fc2f4 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise { await expect(composerInput(page)).not.toBeFocused(); } +/** Blur the editor without anything else taking focus. */ +export async function blurComposerInput(page: Page): Promise { + await composerInput(page).blur(); + await expectComposerNotFocused(page); +} + export async function submitMessage(page: Page, text: string): Promise { const input = composerInput(page); await expect(input).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 1909b4f31b..500ce8bd41 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise export async function expectMobileAgentSidebarHidden(page: Page): Promise { await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 }); } + +/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */ +export async function swipeOpenMobileAgentSidebar(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(8, y); + await page.mouse.down(); + await page.mouse.move(40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); + await page.mouse.up(); +} + +/** Drag from the right edge to open the compact Explorer over the agent. */ +export async function swipeOpenMobileExplorer(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(viewport.width - 8, y); + await page.mouse.down(); + await page.mouse.move(viewport.width - 40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 }); + await page.mouse.up(); +} + +export async function closeMobileExplorer(page: Page): Promise { + const closeButton = page.getByTestId("explorer-close"); + await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await closeButton.click(); +} + +export async function expectMobileExplorerVisible(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 }); +} + +export async function expectMobileExplorerHidden(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 }); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 21bf7f6679..10d670fcae 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1196,11 +1196,16 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) { // A compact overlay panel (agent list, explorer) covers the pane without taking its // focus. The composer gives up input from the moment the user asks for the panel, // not when its motion settles, so the exit action and autocomplete never race it. + // Pane focus stays put, so the keyboard the panel dismisses is a blur in the + // user's own pane: closing the panel does not bring focus back. const isCompact = useIsCompactFormFactor(); const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent"); const coveredByOverlayPanel = isCompact && overlayPanelRequested; return ( - + diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a1b8c0fdbb..01051bd52c 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -524,8 +524,8 @@ function MessageInputAutoFocus({ autoFocusKey: string | undefined; textInputRef: React.MutableRefObject; }) { - const { isActiveComposer } = useComposerKeyboardScope(); - useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey); + const { isPaneFocused } = useComposerKeyboardScope(); + useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey); return null; } @@ -1236,7 +1236,7 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); - const { isActiveComposer } = useComposerKeyboardScope(); + const { isPaneFocused } = useComposerKeyboardScope(); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); @@ -1264,8 +1264,8 @@ export const MessageInput = forwardRef( const focusIntentRef = useRef(false); const valueRef = useRef(value); const selectionRef = useRef({ start: value.length, end: value.length }); - const activeComposerRef = useRef(isActiveComposer); - activeComposerRef.current = isActiveComposer; + const paneFocusedRef = useRef(isPaneFocused); + paneFocusedRef.current = isPaneFocused; const [webTextArea, setWebTextArea] = useState(null); const appliedTextReplacementKeyRef = useRef(textReplacement.key); const webTextareaRef = useRef(null); @@ -1285,9 +1285,13 @@ export const MessageInput = forwardRef( webTextareaRef.current = element; setWebTextArea(element); }, []); + // Focus intent outlives a blur only while the pane is not the user's pane (a retained + // tab hidden, a sheet over it); the pane coming back restores focus. A blur while the + // pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard, + // or a compact overlay panel did on their behalf, and nothing later refocuses. useLayoutEffect(() => { - if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus(); - }, [isActiveComposer]); + if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus(); + }, [isPaneFocused]); // The compact and desktop screens mount the editor in different trees, so a // viewport that crosses the breakpoint remounts it. The draft owner brings the // text back; the session brings the caret and focus intent back. On web the @@ -1305,7 +1309,7 @@ export const MessageInput = forwardRef( textInputRef.current?.setSelection(selection); if (retained.focused) { focusIntentRef.current = true; - if (activeComposerRef.current) textInputRef.current?.focus(); + if (paneFocusedRef.current) textInputRef.current?.focus(); } } return () => { @@ -1807,13 +1811,11 @@ export const MessageInput = forwardRef( }, [onFocusChange]); const handleInputBlur = useCallback(() => { - // Fullscreen is nothing but the editor, so a blur there (a retained tab being - // hidden, the keyboard dismissed) keeps the intent: showing it again refocuses. - if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false; + if (paneFocusedRef.current) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); - }, [isFullscreen, onFocusChange]); + }, [onFocusChange]); const handlePasteError = useCallback( (message: string) => { diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx index b254c53a51..20b57dde28 100644 --- a/packages/app/src/composer/keyboard-scope.tsx +++ b/packages/app/src/composer/keyboard-scope.tsx @@ -7,18 +7,35 @@ import { } from "react"; interface ComposerKeyboardScopeValue { + /** + * The pane this composer sits in is the user's pane. Focus restoration follows it: + * the editor keeps its focus intent across blurs that happen while the pane is not + * the user's (a retained tab hidden, a sheet over it) and brings focus back when + * the pane is theirs again. + */ + isPaneFocused: boolean; + /** + * The composer takes input right now: keyboard actions, shortcuts, autocomplete, + * and the fullscreen exit action. False while a compact overlay panel covers the + * pane, which does not take pane focus. + */ isActiveComposer: boolean; } const ComposerKeyboardScopeContext = createContext({ + isPaneFocused: false, isActiveComposer: false, }); export function ComposerKeyboardScopeProvider({ + isPaneFocused, isActiveComposer, children, }: PropsWithChildren): ReactElement { - const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]); + const value = useMemo( + () => ({ isPaneFocused, isActiveComposer }), + [isPaneFocused, isActiveComposer], + ); return ( {children} From 8ff2424da1ae807999fd84fa61b10250a97e34ed Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:08:39 +0200 Subject: [PATCH 06/17] Return checkout diff hunks from subdirectory workspaces (#6882) * fix(git): return checkout diff hunks from subdirectory workspaces Checkout paths are repository-relative, but one-shot diff reads used the workspace subdirectory for Git pathspecs. Resolve the worktree root once so patch selection and file reads use the same coordinates as the live subscription. * test(server): await bounded discard fixture cleanup on Windows The discard assertions pass but Windows can retain a directory lock during cleanup. Await asynchronous removal with bounded retries after daemon shutdown, preserving failure for persistent locks. Matches the verified cleanup correction in #6912 for #6918. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- .../checkout-diff-content.e2e.test.ts | 75 ++++++++++++++++++- .../server/daemon-e2e/filesystem.e2e.test.ts | 5 +- packages/server/src/utils/checkout-git.ts | 2 +- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index beffa3b4ce..a69a2c3c2a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index f0cd4495e1..75302d4168 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -85,3 +85,76 @@ test.each(["uncommitted", "base", "commit"] as const)( }, 60_000, ); + +test.each( + (["uncommitted", "base"] as const).flatMap((mode) => + (["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })), + ), +)( + "$mode $shape diff includes hunks from a repository subdirectory", + async ({ mode, shape }) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-")); + + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "core.autocrlf", "false"); + const subdirectory = join(cwd, "sub"); + mkdirSync(subdirectory); + const unchangedLines = "unchanged\n".repeat(10); + writeFileSync(join(subdirectory, "normal.txt"), `before\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "old sibling\n"); + } + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + if (mode === "base") { + git(cwd, "checkout", "-b", "feature"); + } + const filename = shape === "rename" ? "renamed.txt" : "normal.txt"; + if (shape === "rename") { + git(cwd, "mv", "sub/normal.txt", `sub/${filename}`); + } + writeFileSync(join(subdirectory, filename), `after\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "new sibling\n"); + } + if (mode === "base") { + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + } + + const compare = { mode, baseRef: "main" }; + const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); + const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); + const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); + const liveDiff = await subscription.ready; + await subscription.release(); + + expect(rootDiff.error).toBeNull(); + expect(subdirectoryDiff.error).toBeNull(); + expect(liveDiff.error).toBeNull(); + expect(liveDiff.files).toEqual(rootDiff.files); + expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); + const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); + expect(file).toMatchObject({ + additions: 1, + deletions: 1, + ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + expect(subdirectoryDiff.files).toEqual(rootDiff.files); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 0c99744e62..da52c4dac4 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -8,6 +8,7 @@ import { readdirSync, readlinkSync, } from "fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "os"; import path from "path"; import { execFileSync } from "node:child_process"; @@ -100,7 +101,9 @@ describe("checkout discard", () => { } } finally { // afterEach shuts down the daemon and its repository observers first. - onTestFinished(() => rmSync(cwd, { recursive: true, force: true })); + onTestFinished(() => + rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }), + ); } }, ); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index bda89506b5..64148c26e4 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -3423,7 +3423,7 @@ export async function getCheckoutDiff( compare: CheckoutDiffCompare, context?: CheckoutContext, ): Promise { - await requireGitRepo(cwd); + cwd = await requireGitWorktreeRoot(cwd); const refsForDiff = await resolveCheckoutDiffRefs(cwd, compare, context); if (!refsForDiff) { From 9ab99a6e1e004fdcffde43203e8c7adebf971f85 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 11:19:32 +0700 Subject: [PATCH 07/17] feat(website): show sponsors on paseo.sh and at the top of the README (#6956) --- README.ja.md | 17 ++- README.ko.md | 17 ++- README.md | 17 ++- README.zh-CN.md | 17 ++- packages/website/e2e/sponsors.spec.ts | 20 ++++ .../public/sponsors/hoplite-on-dark.svg | 1 + .../public/sponsors/hoplite-on-light.svg | 1 + .../src/components/docs-source-footer.tsx | 13 +- .../website/src/components/landing-page.tsx | 4 +- .../website/src/components/site-footer.tsx | 6 +- .../website/src/components/sponsorship.tsx | 112 +++++++++++------- packages/website/src/data/sponsors.test.ts | 64 ++++++++++ packages/website/src/data/sponsors.ts | 56 ++++++--- packages/website/src/routes/docs.tsx | 2 + packages/website/src/styles.css | 17 --- 15 files changed, 271 insertions(+), 93 deletions(-) create mode 100644 packages/website/e2e/sponsors.spec.ts create mode 100644 packages/website/public/sponsors/hoplite-on-dark.svg create mode 100644 packages/website/public/sponsors/hoplite-on-light.svg create mode 100644 packages/website/src/data/sponsors.test.ts diff --git a/README.ja.md b/README.ja.md index c2d1ad88a0..5d1c8f5ebb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -53,6 +53,17 @@ - **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。 - **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。 +## スポンサー + + + + + + + + +
Hoplite
Cloud coding agents
Paseo のスポンサーになるPaseo のスポンサーになる
+ ## はじめかた Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。 @@ -151,11 +162,9 @@ npm run build:server npm run typecheck ``` -## スポンサー +## Paseo を支援する -Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。 - - +Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。 ## 関連プロジェクト diff --git a/README.ko.md b/README.ko.md index 60581767cd..7f2098221d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,6 +47,17 @@ - **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다. - **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다. +## 스폰서 + + + + + + + + +
Hoplite
Cloud coding agents
Paseo 스폰서 되기Paseo 스폰서 되기
+ ## 시작하기 Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다. @@ -161,11 +172,9 @@ npm run build:server npm run typecheck ``` -## 스폰서 +## Paseo 후원하기 -Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다. - - +Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다. ## 관련 프로젝트 diff --git a/README.md b/README.md index 2fc0fc8f3d..91310cd7c7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,17 @@ Paseo is an open source agentic development environment for desktop, mobile, web [Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser. +## Sponsors + + + + + + + + +
Hoplite
Cloud coding agents
Become a sponsorBecome a sponsor
+ ## Plugins Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, @@ -207,11 +218,9 @@ npm run build:server npm run typecheck ``` -## Sponsors +## Support Paseo -Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. - - +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs. ## Related projects diff --git a/README.zh-CN.md b/README.zh-CN.md index dfdf673b2e..745071f0fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -53,6 +53,17 @@ - **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。 - **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。 +## 赞助商 + + + + + + + + +
Hoplite
Cloud coding agents
成为 Paseo 赞助商成为 Paseo 赞助商
+ ## 快速开始 Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。 @@ -151,11 +162,9 @@ npm run build:server npm run typecheck ``` -## 赞助 +## 支持 Paseo -Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。 - - +Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。 ## 相关项目 diff --git a/packages/website/e2e/sponsors.spec.ts b/packages/website/e2e/sponsors.spec.ts new file mode 100644 index 0000000000..5dc9aae92d --- /dev/null +++ b/packages/website/e2e/sponsors.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "playwright/test"; + +const PAGES = ["/", "/docs/connectivity", "/changelog"]; + +for (const path of PAGES) { + test(`${path} shows the sponsors and invites the next one`, async ({ page }) => { + await page.goto(path); + + const sponsors = page.getByRole("region", { name: /^(Sponsors|Sponsored by)$/ }); + await expect(sponsors.getByRole("link", { name: "Hoplite" })).toHaveAttribute( + "href", + "https://hoplite.sh/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor", + ); + await expect(sponsors.getByText("Cloud coding agents", { exact: true })).toBeVisible(); + await expect(sponsors.getByRole("link", { name: "Become a sponsor" })).toHaveAttribute( + "href", + "/sponsor#spot", + ); + }); +} diff --git a/packages/website/public/sponsors/hoplite-on-dark.svg b/packages/website/public/sponsors/hoplite-on-dark.svg new file mode 100644 index 0000000000..55b1da0592 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-dark.svg @@ -0,0 +1 @@ + diff --git a/packages/website/public/sponsors/hoplite-on-light.svg b/packages/website/public/sponsors/hoplite-on-light.svg new file mode 100644 index 0000000000..144d1a59c9 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-light.svg @@ -0,0 +1 @@ + diff --git a/packages/website/src/components/docs-source-footer.tsx b/packages/website/src/components/docs-source-footer.tsx index 69a9711e80..2b5ea3f131 100644 --- a/packages/website/src/components/docs-source-footer.tsx +++ b/packages/website/src/components/docs-source-footer.tsx @@ -1,3 +1,4 @@ +import { ExternalLink } from "lucide-react"; import type { Doc } from "~/docs"; const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main"; @@ -6,9 +7,15 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) { const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`; return ( -