From 555d10f046b03dad67660e790d20d985a5061148 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 16:02:09 +0000 Subject: [PATCH 1/7] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 40fc0dfd5..1d826ced4 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-t6vqegESlQGlbkOfjpjOHbf6ufiDeBl/ApFtQHvOeLc= +sha256-vNhmXjWV1wf17JHVX6tpTz4CKeCE7hpyBLj8nzPVUH0= From 3786cf35693395c295dc208eae7ae97c05b8b767 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Wed, 20 May 2026 06:47:10 +0100 Subject: [PATCH 2/7] Wait for Cursor ACP slash commands before listing. (#1099) cursor-agent publishes commands asynchronously via available_commands_update, so listCommands() returned an empty cache when called right after session/new. Enable waitForInitialCommands only on CursorACPAgentClient with a 10s timeout; other ACP providers keep the immediate-return default. Co-authored-by: Cursor --- .../server/agent/providers/acp-agent.test.ts | 22 ++++++++++++++++++- .../agent/providers/cursor-acp-agent.ts | 12 +++++++++- .../agent/providers/generic-acp-agent.ts | 4 ++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 21b417172..1e13ef190 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1416,7 +1416,27 @@ describe("transformPiModels", () => { describe("ACPAgentSession slash commands", () => { test("returns immediately for ACP sessions that do not wait for async command discovery", async () => { - const session = createSession(); + const session = new ACPAgentSession( + { + provider: "claude-acp", + cwd: "/tmp/paseo-acp-test", + }, + { + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + waitForInitialCommands: false, + }, + ); await expect(session.listCommands()).resolves.toEqual([]); }); diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts index 582d44c01..ad253a4d1 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -14,6 +14,7 @@ interface CursorACPAgentClientOptions { } const CURSOR_MODELS_TIMEOUT_MS = 10_000; +const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/; export class CursorACPAgentClient extends GenericACPAgentClient { @@ -21,7 +22,16 @@ export class CursorACPAgentClient extends GenericACPAgentClient { private readonly env?: Record; constructor(options: CursorACPAgentClientOptions) { - super(options); + super({ + logger: options.logger, + command: options.command, + env: options.env, + providerId: options.providerId, + label: options.label, + // cursor-agent publishes slash commands asynchronously via available_commands_update. + waitForInitialCommands: true, + initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, + }); this.cursorCommand = options.command; this.env = options.env; } diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 8eb3ea5cc..303f1e5d1 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -27,6 +27,8 @@ interface GenericACPAgentClientOptions { env?: Record; providerId?: string; label?: string; + waitForInitialCommands?: boolean; + initialCommandsWaitTimeoutMs?: number; } export class GenericACPAgentClient extends ACPAgentClient { @@ -42,6 +44,8 @@ export class GenericACPAgentClient extends ACPAgentClient { env: options.env, }, defaultCommand: options.command, + waitForInitialCommands: options.waitForInitialCommands, + initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, }); this.command = options.command; From 0d495a9625271fb059b9eca9b87ad149daf93d95 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 20 May 2026 15:00:29 +0800 Subject: [PATCH 3/7] Fix startup workspace restore (#1111) --- packages/app/src/app/index.test.tsx | 18 ++++-------------- packages/app/src/app/index.tsx | 20 +++++++++----------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx index cd6540363..0740675ad 100644 --- a/packages/app/src/app/index.test.tsx +++ b/packages/app/src/app/index.test.tsx @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HostRuntimeBootstrapState } from "./_layout"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => { +const { redirectMock, state } = vi.hoisted(() => { const hoistedState = { pathname: "/", bootstrapState: { @@ -23,7 +23,6 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => { }; return { - navigateToWorkspaceMock: vi.fn(), redirectMock: vi.fn(), state: hoistedState, }; @@ -51,7 +50,6 @@ vi.mock("@/screens/startup-splash-screen", () => ({ })); vi.mock("@/stores/navigation-active-workspace-store", () => ({ - navigateToWorkspace: navigateToWorkspaceMock, useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded, useLastWorkspaceSelection: () => state.workspaceSelection, })); @@ -72,7 +70,6 @@ describe("Index route startup navigation", () => { state.anyOnlineHostServerId = null; state.isWorkspaceSelectionLoaded = true; state.workspaceSelection = null; - navigateToWorkspaceMock.mockReset(); redirectMock.mockReset(); container = document.createElement("div"); @@ -108,7 +105,6 @@ describe("Index route startup navigation", () => { await renderIndex(); expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); - expect(navigateToWorkspaceMock).not.toHaveBeenCalled(); expect(redirectMock).not.toHaveBeenCalled(); }); @@ -118,11 +114,8 @@ describe("Index route startup navigation", () => { await renderIndex(); - expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", { - currentPathname: "/", - }); - expect(redirectMock).not.toHaveBeenCalled(); - expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); + expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + expect(container.querySelector("[data-testid='redirect']")).not.toBeNull(); }); it("restores the persisted workspace even when the first online host is different", async () => { @@ -131,10 +124,7 @@ describe("Index route startup navigation", () => { await renderIndex(); - expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", { - currentPathname: "/", - }); - expect(redirectMock).not.toHaveBeenCalled(); + expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); }); it("navigates to the host root when no persisted workspace exists", async () => { diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index d2fbbd53d..4156f0a37 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -7,11 +7,11 @@ import { resolveStartupWorkspaceSelection, } from "@/app/host-runtime-bootstrap"; import { - navigateToWorkspace, useIsLastWorkspaceSelectionHydrated, useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; const isDesktop = shouldUseDesktopDaemon(); @@ -37,17 +37,15 @@ export default function Index() { hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); - React.useEffect(() => { - if (!startupWorkspaceSelection) { - return; - } - navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, { - currentPathname: pathname, - }); - }, [pathname, startupWorkspaceSelection]); - if (startupWorkspaceSelection) { - return ; + return ( + + ); } if (redirectRoute) { From ca913728d9424f28e1e3ca1496184af564c53cbd Mon Sep 17 00:00:00 2001 From: Zexin Yuan Date: Wed, 20 May 2026 15:20:13 +0800 Subject: [PATCH 4/7] Add publicUseTls option to NixOS module (#1106) --- nix/module.nix | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/nix/module.nix b/nix/module.nix index cb2a325fe..72ab1bf81 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -124,6 +124,17 @@ in default = true; description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`."; }; + + publicUseTls = lib.mkOption { + type = lib.types.nullOr lib.types.bool; + default = null; + description = '' + Whether the public (client-facing) relay endpoint uses TLS. + When `null` (default), the daemon falls back to `relay.useTls`. + Override when the internal path is plain `ws://` behind a + TLS-terminating reverse proxy. + ''; + }; }; inheritUserEnvironment = lib.mkOption { @@ -253,6 +264,8 @@ in } // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") { PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}"; PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false"; + } // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.publicUseTls != null) { + PASEO_RELAY_PUBLIC_USE_TLS = if cfg.relay.publicUseTls then "true" else "false"; } // cfg.environment; serviceConfig = { From 905a0985f95faff8eeb818887ecb3f17f64b7307 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 20 May 2026 15:23:05 +0800 Subject: [PATCH 5/7] Forward create agent env to provider launches (#1112) --- packages/cli/src/commands/agent/run.ts | 47 ++++++++++-- packages/server/src/client/daemon-client.ts | 3 + .../src/server/agent/agent-manager.test.ts | 75 +++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 6 +- .../agent/providers/opencode-agent.test.ts | 34 +++++++++ .../server/agent/providers/opencode-agent.ts | 10 ++- .../agent/providers/opencode/runtime.ts | 5 +- .../providers/opencode/server-manager.ts | 35 ++++++++- .../providers/opencode/test-server-manager.ts | 7 +- .../test-utils/test-opencode-runtime.ts | 13 +++- .../server/agent/providers/pi/agent.test.ts | 16 ++++ .../src/server/agent/providers/pi/agent.ts | 3 +- .../src/server/agent/providers/pi/runtime.ts | 9 ++- packages/server/src/server/messages.test.ts | 41 +++++++++- packages/server/src/server/session.ts | 2 + packages/server/src/shared/messages.ts | 1 + 16 files changed, 286 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 782eb516f..b934013a0 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -46,6 +46,12 @@ export function addRunOptions(cmd: Command): Command { [], ) .option("--cwd ", "Working directory (default: current)") + .option( + "--env ", + "Set environment variable(s) for the agent process (can be used multiple times)", + collectMultiple, + [], + ) .option( "--label ", "Add label(s) to the agent (can be used multiple times)", @@ -95,6 +101,7 @@ export interface AgentRunOptions extends CommandOptions { base?: string; image?: string[]; cwd?: string; + env?: string[]; label?: string[]; waitTimeout?: string; outputSchema?: string; @@ -320,15 +327,41 @@ function loadRunImages( } function parseRunLabels(labelFlags: string[] | undefined): Record { + return parseKeyValueFlags(labelFlags, { + flagName: "--label", + code: "INVALID_LABEL", + noun: "label", + pluralNoun: "Labels", + }); +} + +function parseRunEnv(envFlags: string[] | undefined): Record { + return parseKeyValueFlags(envFlags, { + flagName: "--env", + code: "INVALID_ENV", + noun: "environment variable", + pluralNoun: "Environment variables", + }); +} + +function parseKeyValueFlags( + flags: string[] | undefined, + options: { + flagName: string; + code: CommandError["code"]; + noun: string; + pluralNoun: string; + }, +): Record { const labels: Record = {}; - if (!labelFlags) return labels; - for (const labelStr of labelFlags) { + if (!flags) return labels; + for (const labelStr of flags) { const eqIndex = labelStr.indexOf("="); if (eqIndex === -1) { throw { - code: "INVALID_LABEL", - message: `Invalid label format: ${labelStr}`, - details: "Labels must be in key=value format", + code: options.code, + message: `Invalid ${options.noun} format: ${labelStr}`, + details: `${options.pluralNoun} must be in key=value format`, } satisfies CommandError; } const key = labelStr.slice(0, eqIndex); @@ -394,6 +427,8 @@ export async function runRunCommand( : undefined; const labels = parseRunLabels(options.label); + const env = parseRunEnv(options.env); + const requestEnv = Object.keys(env).length > 0 ? env : undefined; if (outputSchema) { let structuredAgent: AgentSnapshotPayload | null = null; @@ -410,6 +445,7 @@ export async function runRunCommand( initialPrompt: structuredPrompt, outputSchema, images, + env: requestEnv, git, worktreeName: options.worktree, labels: Object.keys(labels).length > 0 ? labels : undefined, @@ -479,6 +515,7 @@ export async function runRunCommand( thinkingOptionId, initialPrompt: prompt, images, + env: requestEnv, git, worktreeName: options.worktree, labels: Object.keys(labels).length > 0 ? labels : undefined, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 9ce4a06ea..382eb3d0f 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -249,6 +249,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; + env?: CreateAgentRequestMessage["env"]; workspaceId?: string; initialPrompt?: string; clientMessageId?: string; @@ -1837,6 +1838,7 @@ export class DaemonClient { type: "create_agent_request", requestId, config, + ...(options.env ? { env: options.env } : {}), ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), @@ -4667,6 +4669,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon config, provider, cwd, + env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, images: _images, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index b22cbffee..be71518f2 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from "vitest"; +import { spawn } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -147,6 +148,46 @@ class TestAgentClient implements AgentClient { } } +class EnvProbeAgentClient extends TestAgentClient { + probe: Promise<{ probe: string | null; agentId: string | null }> | null = null; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + const script = ` + process.stdout.write(JSON.stringify({ + probe: process.env.CHUNK14_PROBE ?? null, + agentId: process.env.PASEO_AGENT_ID ?? null + })); + `; + const child = spawn(process.execPath, ["-e", script], { + cwd: config.cwd, + env: { ...process.env, ...launchContext?.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + this.probe = new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`env probe exited ${code}: ${stderr}`)); + return; + } + resolve(JSON.parse(stdout) as { probe: string | null; agentId: string | null }); + }); + }); + return new TestAgentSession(config); + } +} + class TestAgentSession implements AgentSession { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; @@ -373,6 +414,40 @@ test("normalizeConfig injects the provider default model when omitted", async () expect(snapshot.config.modeId).toBe("auto"); }); +test("createAgent forwards request env into the spawned provider process", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-env-test-")); + const client = new EnvProbeAgentClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + logger, + idFactory: () => "00000000-0000-4000-8000-00000000e001", + }); + + try { + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { + env: { + CHUNK14_PROBE: "expected", + }, + }, + ); + + await expect(client.probe).resolves.toEqual({ + probe: "expected", + agentId: "00000000-0000-4000-8000-00000000e001", + }); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("normalizeConfig strips legacy 'default' model id", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 3c3a95d79..00b3d5fc8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -805,6 +805,7 @@ export class AgentManager { labels?: Record; workspaceId?: string; initialPrompt?: string; + env?: Record; persistSession?: boolean; }, ): Promise { @@ -826,7 +827,7 @@ export class AgentManager { const normalizedConfig = this.applyDaemonAppendSystemPrompt( await this.normalizeConfig(injectedConfig), ); - const launchContext = this.buildLaunchContext(resolvedAgentId); + const launchContext = this.buildLaunchContext(resolvedAgentId, options?.env); const client = await this.requireAvailableClient({ provider: normalizedConfig.provider, }); @@ -3469,10 +3470,11 @@ export class AgentManager { : next; } - private buildLaunchContext(agentId: string): AgentLaunchContext { + private buildLaunchContext(agentId: string, env?: Record): AgentLaunchContext { return { agentId, env: { + ...env, PASEO_AGENT_ID: agentId, }, }; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 215417ba6..bd2c72afb 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1138,6 +1138,40 @@ describe("OpenCode adapter startTurn error handling", () => { }); }); +describe("OpenCodeAgentClient env", () => { + test("passes launch-context env to env-specific server acquisition", async () => { + const runtime = new TestOpenCodeRuntime(); + const openCodeClient = new TestOpenCodeClient(); + runtime.enqueueClient(openCodeClient); + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); + + try { + const session = await client.createSession( + { + provider: "opencode", + cwd, + }, + { + env: { + CHUNK14_PROBE: "expected", + }, + }, + ); + await session.close(); + + expect(runtime.acquisitions[0]).toMatchObject({ + force: false, + env: { + CHUNK14_PROBE: "expected", + }, + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); + describe("OpenCode persisted sessions", () => { test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => { const runtime = new TestOpenCodeRuntime(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 9e28b4b73..ec28c29c0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -961,7 +961,10 @@ interface OpenCodeAgentClientDeps { class ProductionOpenCodeRuntime implements OpenCodeRuntime { constructor(private readonly serverManager: OpenCodeServerManager) {} - async acquireServer(options: { force: boolean }): Promise { + async acquireServer(options: { + force: boolean; + env?: Record; + }): Promise { return this.serverManager.acquire(options); } @@ -1007,7 +1010,10 @@ export class OpenCodeAgentClient implements AgentClient { options?: AgentCreateSessionOptions, ): Promise { const openCodeConfig = this.assertConfig(config); - const acquisition = await this.runtime.acquireServer({ force: false }); + const acquisition = await this.runtime.acquireServer({ + force: false, + env: launchContext?.env, + }); const { url } = acquisition.server; const client = this.runtime.createClient({ baseUrl: url, diff --git a/packages/server/src/server/agent/providers/opencode/runtime.ts b/packages/server/src/server/agent/providers/opencode/runtime.ts index ff30f72f7..4c4cb6440 100644 --- a/packages/server/src/server/agent/providers/opencode/runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/runtime.ts @@ -10,7 +10,10 @@ export interface OpenCodeServerAcquisition { } export interface OpenCodeRuntime { - acquireServer(options: { force: boolean }): Promise; + acquireServer(options: { + force: boolean; + env?: Record; + }): Promise; ensureServerRunning(): Promise<{ port: number; url: string }>; createClient(options: { baseUrl: string; directory: string }): OpencodeClient; shutdown(): Promise; diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 1705ca5fa..1a4b4db94 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -21,7 +21,10 @@ export interface OpenCodeServerAcquisition { export interface OpenCodeServerManagerLike { ensureRunning(): Promise<{ port: number; url: string }>; - acquire(options: { force: boolean }): Promise; + acquire(options: { + force: boolean; + env?: Record; + }): Promise; } export interface OpenCodeServerGeneration { @@ -91,10 +94,22 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { return acquisition.server; } - async acquire(options: { force: boolean }): Promise { + async acquire(options: { + force: boolean; + env?: Record; + }): Promise { + if (options.env) { + const server = await this.startDedicatedServer(options.env); + return this.acquireServer(server); + } + const server = options.force ? await this.getForcedRefreshServer() : await this.getCurrentServer(); + return this.acquireServer(server); + } + + private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition { server.refCount += 1; let released = false; return { @@ -164,7 +179,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } } - private async startServer(): Promise { + private async startDedicatedServer( + env: Record, + ): Promise { + const server = await this.startServer(env); + server.retired = true; + this.retiredServers.add(server); + return server; + } + + private async startServer(launchEnv?: Record): Promise { const port = await findAvailablePort(); const url = `http://127.0.0.1:${port}`; const launchPrefix = await resolveProviderCommandPrefix( @@ -179,7 +203,10 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], - ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }), + ...createProviderEnvSpec({ + runtimeSettings: this.runtimeSettings, + overlays: [launchEnv], + }), }, ); diff --git a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts index baab73f9b..cda20879d 100644 --- a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -2,6 +2,7 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./ser export interface TestOpenCodeServerAcquisition { force: boolean; + env?: Record; released: boolean; } @@ -15,9 +16,13 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { return this.server; } - async acquire(options: { force: boolean }): Promise { + async acquire(options: { + force: boolean; + env?: Record; + }): Promise { const acquisition: TestOpenCodeServerAcquisition = { force: options.force, + env: options.env, released: false, }; this.acquisitions.push(acquisition); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts index 0499dbde4..15e1c8ebd 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts @@ -8,7 +8,11 @@ interface OpenCodeResponse { } export class TestOpenCodeRuntime implements OpenCodeRuntime { - readonly acquisitions: Array<{ force: boolean; releaseCount: number }> = []; + readonly acquisitions: Array<{ + force: boolean; + env?: Record; + releaseCount: number; + }> = []; readonly clientCreations: Array<{ baseUrl: string; directory: string }> = []; private readonly clients: TestOpenCodeClient[] = []; @@ -18,8 +22,11 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime { this.clients.push(client); } - async acquireServer(options: { force: boolean }): Promise { - const acquisition = { force: options.force, releaseCount: 0 }; + async acquireServer(options: { + force: boolean; + env?: Record; + }): Promise { + const acquisition = { force: options.force, env: options.env, releaseCount: 0 }; this.acquisitions.push(acquisition); return { server: this.server, diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 30a2d6d55..0984a7095 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -42,6 +42,22 @@ async function createSession(pi = new FakePi()): Promise<{ return { pi, session, events }; } +test("forwards launch-context env to the Pi process launch", async () => { + const pi = new FakePi(); + const client = createClient(pi); + const session = await client.createSession(createConfig(), { + env: { + CHUNK14_PROBE: "expected", + }, + }); + + expect(pi.recordedLaunches[0]?.env).toEqual({ + CHUNK14_PROBE: "expected", + }); + + await session.close(); +}); + class SessionEvents { private readonly events: AgentStreamEvent[] = []; private readonly waiters: Array<{ diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 3c4607c99..1f5dc012b 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -843,7 +843,7 @@ export class PiRpcAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, - _launchContext?: AgentLaunchContext, + launchContext?: AgentLaunchContext, ): Promise { const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers); let runtimeSession: PiRuntimeSession; @@ -857,6 +857,7 @@ export class PiRpcAgentClient implements AgentClient { config.systemPrompt, config.daemonAppendSystemPrompt, ), + env: launchContext?.env, mcpConfigPath: mcpConfig?.path, }); } catch (error) { diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 0e7a50b5e..8925ed1a0 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -21,6 +21,7 @@ export interface PiRuntimeLaunch { export interface PiStartSessionInput { cwd: string; + env?: Record; model?: string; thinkingOptionId?: string; session?: string; @@ -84,7 +85,13 @@ export function buildPiLaunch(input: { return { cwd: input.session.cwd, argv, - env: input.runtimeSettings?.env, + env: + input.runtimeSettings?.env || input.session.env + ? { + ...input.runtimeSettings?.env, + ...input.session.env, + } + : undefined, model: input.session.model, thinkingOptionId: input.session.thinkingOptionId, session: input.session.session, diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts index cfcfb285c..411d81c78 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -1,9 +1,48 @@ import { describe, expect, test } from "vitest"; import type { AgentStreamEvent } from "./agent/agent-sdk-types.js"; -import { serializeAgentStreamEvent } from "./messages.js"; +import { SessionInboundMessageSchema, serializeAgentStreamEvent } from "./messages.js"; describe("serializeAgentStreamEvent", () => { + test("accepts create_agent_request env records", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "req-env", + config: { + provider: "codex", + cwd: "/tmp", + }, + env: { + CHUNK14_PROBE: "expected", + }, + attachments: [], + }); + + expect(parsed).toMatchObject({ + type: "create_agent_request", + env: { + CHUNK14_PROBE: "expected", + }, + }); + }); + + test("rejects non-string create_agent_request env values", () => { + const parsed = SessionInboundMessageSchema.safeParse({ + type: "create_agent_request", + requestId: "req-env", + config: { + provider: "codex", + cwd: "/tmp", + }, + env: { + CHUNK14_PROBE: 14, + }, + attachments: [], + }); + + expect(parsed.success).toBe(false); + }); + test("preserves user_message text as-is", () => { const event: AgentStreamEvent = { type: "timeline", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 46a0978cc..edfc7c18c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3084,6 +3084,7 @@ export class Session { images, attachments, labels, + env, } = msg; this.sessionLogger.info( { cwd: config.cwd, provider: config.provider, worktreeName }, @@ -3124,6 +3125,7 @@ export class Session { labels, workspaceId: resolvedWorkspace.workspaceId, initialPrompt: trimmedPrompt, + env, }); await this.forwardAgentUpdate(snapshot); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 574a1eb05..497b241ad 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1061,6 +1061,7 @@ export type GitSetupOptions = z.infer; export const CreateAgentRequestMessageSchema = z.object({ type: z.literal("create_agent_request"), config: AgentSessionConfigSchema, + env: z.record(z.string()).optional(), workspaceId: z.string().optional(), worktreeName: z.string().optional(), initialPrompt: z.string().optional(), From f933bf6b3ae050ef215cfb5715b2c685792a77be Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 20 May 2026 16:55:00 +0800 Subject: [PATCH 6/7] fix: stop leaking CSS rules from dynamic UI styles (#1084) (#1103) * fix: stop leaking CSS rules from dynamic position styles * Fix floating surfaces on web * Stop diff controls growing Unistyles rules * Use inline style escape hatch in diff pane * Centralize dynamic style escape hatches * Restore startup workspace navigation * Upgrade Unistyles for web cleanup fix * Update review style test for inline styles * Use redirect for startup workspace restore * Update startup redirect test --- docs/unistyles.md | 28 ++++ package-lock.json | 21 +-- packages/app/package.json | 2 +- packages/app/src/components/diff-viewer.tsx | 54 +++++--- packages/app/src/components/file-pane.tsx | 6 +- .../app/src/components/tool-call-details.tsx | 7 +- packages/app/src/components/ui/combobox.tsx | 21 +-- .../app/src/components/ui/context-menu.tsx | 18 +-- .../app/src/components/ui/dropdown-menu.tsx | 53 +++----- packages/app/src/components/ui/floating.tsx | 63 +++++++++ packages/app/src/components/ui/tooltip.tsx | 22 +-- .../src/components/web-desktop-scrollbar.tsx | 56 +++++--- .../src/components/workspace-hover-card.tsx | 27 ++-- packages/app/src/git/actions-split-button.tsx | 17 +-- packages/app/src/git/diff-pane.tsx | 125 ++++++++++++------ packages/app/src/review/surface.test.tsx | 3 +- packages/app/src/review/surface.tsx | 6 +- .../app/src/styles/unistyles-inline-style.ts | 23 ++++ 18 files changed, 366 insertions(+), 186 deletions(-) create mode 100644 packages/app/src/components/ui/floating.tsx create mode 100644 packages/app/src/styles/unistyles-inline-style.ts diff --git a/docs/unistyles.md b/docs/unistyles.md index 4a9e179bb..e2a93de82 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -80,6 +80,34 @@ The important detail: the automatic native path tracks `props.style`. It does no [`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant. +## Dynamic Pixel Styles On Web + +Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth. + +Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers. + +## Inline Style Escape Hatch + +When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`. + +```tsx +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; + +const styles = StyleSheet.create({ + thumb: { + position: "absolute", + }, +}); + +; +``` + +This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary. + +Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes. + +When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals. + ## Main Gotcha: `contentContainerStyle` `ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section. diff --git a/package-lock.json b/package-lock.json index b8cc87493..e4dbe0819 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31040,17 +31040,15 @@ } }, "node_modules/react-native-unistyles": { - "version": "3.0.24", - "resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.0.24.tgz", - "integrity": "sha512-JWwkoXGSrxSzTBphBx1oYHTGQRyI7fup29D8kpjFdKqBLEXoOA1SzBB8phHb51kC4dCawaP1iOuyXoYm0kyCvw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz", + "integrity": "sha512-S0aXJKZ6zYUViKMol+3on707AWD71fnAM7kmcHjk3FALy5QJicCCzxuluR0IptFQYNUP2dRqnnhgoS1X/U6Jpw==", "license": "MIT", - "workspaces": [ - "example", - "docs", - "expo-example" - ], + "dependencies": { + "@babel/types": "7.29.0" + }, "engines": { - "node": ">= 18.0.0" + "node": ">= 20.18.0" }, "peerDependencies": { "@react-native/normalize-colors": "*", @@ -31061,6 +31059,9 @@ "react-native-reanimated": "*" }, "peerDependenciesMeta": { + "react-native-edge-to-edge": { + "optional": true + }, "react-native-reanimated": { "optional": true } @@ -36562,7 +36563,7 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "^15.14.0", - "react-native-unistyles": "^3.0.15", + "react-native-unistyles": "^3.2.4", "react-native-web": "~0.21.0", "react-native-webview": "^13.16.0", "react-native-worklets": "0.5.1", diff --git a/packages/app/package.json b/packages/app/package.json index 5eabb6ef4..81693ed53 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -90,7 +90,7 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "^15.14.0", - "react-native-unistyles": "^3.0.15", + "react-native-unistyles": "^3.2.4", "react-native-web": "~0.21.0", "react-native-webview": "^13.16.0", "react-native-worklets": "0.5.1", diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index bf4db0f4b..0c2d24c56 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { DiffLine } from "@/utils/tool-call-parsers"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; @@ -95,20 +96,27 @@ export function DiffViewer({ const outerScrollStyle = React.useMemo( () => [ styles.verticalScroll, - maxHeight !== undefined && { maxHeight }, + maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }), fillAvailableHeight && styles.fillHeight, webScrollbarStyle, ], [maxHeight, fillAvailableHeight, webScrollbarStyle], ); const linesContainerStyle = React.useMemo( - () => [styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }], + () => [ + styles.linesContainer, + scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }), + ], [scrollViewWidth], ); const keyedDiffLines = React.useMemo( () => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })), [diffLines], ); + const webVerticalContentStyle = React.useMemo( + () => [styles.verticalContent, fillAvailableHeight && styles.fillHeight], + [fillAvailableHeight], + ); if (!diffLines.length) { return ( @@ -118,29 +126,39 @@ export function DiffViewer({ ); } - return ( + const lines = ( + + {keyedDiffLines.map(({ key, line }) => ( + + ))} + + ); + + const horizontalScroll = ( + + {lines} + + ); + + const content = ( - - - {keyedDiffLines.map(({ key, line }) => ( - - ))} - - + {horizontalScroll} ); + + return content; } const styles = StyleSheet.create((theme) => { diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 1bdb42426..a4e71a3d5 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -17,6 +17,7 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isWeb } from "@/constants/platform"; @@ -120,7 +121,10 @@ const CodeLine = React.memo(function CodeLine({ gutterWidth, highlighted, }: CodeLineProps) { - const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]); + const gutterStyle = useMemo( + () => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })], + [gutterWidth], + ); const lineStyle = useMemo( () => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine], [highlighted], diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 338571804..bad25a0c1 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -13,6 +13,7 @@ import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; @@ -79,7 +80,7 @@ function useDetailStyles( const codeVerticalScrollStyle = useMemo( () => [ styles.codeVerticalScroll, - resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, + resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), shouldFill && styles.fillHeight, webScrollbarStyle, ], @@ -88,7 +89,7 @@ function useDetailStyles( const scrollAreaFillStyle = useMemo( () => [ styles.scrollArea, - resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, + resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), shouldFill && styles.fillHeight, webScrollbarStyle, ], @@ -97,7 +98,7 @@ function useDetailStyles( const scrollAreaStyle = useMemo( () => [ styles.scrollArea, - resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, + resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }), webScrollbarStyle, ], [resolvedMaxHeight, webScrollbarStyle], diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 1a1794963..6bc959ff1 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -20,6 +20,8 @@ import { useWindowDimensions, type LayoutChangeEvent, type PressableStateCallbackType, + type StyleProp, + type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -56,6 +58,7 @@ import { SheetHeaderView, type SheetHeader, } from "@/components/adaptive-modal-sheet"; +import { FloatingSurface } from "@/components/ui/floating"; const IS_WEB = isWeb; @@ -858,7 +861,7 @@ interface DesktopContainerStyleInput { availableHeight: number | undefined; } -function buildDesktopContainerStyle(input: DesktopContainerStyleInput) { +function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp { const { desktopMinWidth, referenceWidth, @@ -877,7 +880,6 @@ function buildDesktopContainerStyle(input: DesktopContainerStyleInput) { ? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) } : null; return [ - styles.desktopContainer, { position: "absolute" as const, minWidth: desktopMinWidth ?? referenceWidth ?? 200, @@ -1070,7 +1072,7 @@ interface DesktopBodyProps { handleClose: () => void; refs: ReturnType["refs"]; shouldUseDesktopFade: boolean; - desktopContainerStyle: unknown; + desktopFrameStyle: StyleProp; handleDesktopContentLayout: (event: LayoutChangeEvent) => void; header: SheetHeader | undefined; stickyHeader: ReactNode; @@ -1191,11 +1193,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { > - )} - + ); @@ -1492,9 +1495,9 @@ export function Combobox({ [theme.colors.palette.zinc], ); - const desktopContainerStyle = useMemo( + const desktopFrameStyle = useMemo( () => - buildDesktopContainerStyle({ + buildDesktopFrameStyle({ desktopMinWidth, referenceWidth, desktopFixedHeight, @@ -1561,7 +1564,7 @@ export function Combobox({ handleClose={handleClose} refs={refs} shouldUseDesktopFade={shouldUseDesktopFade} - desktopContainerStyle={desktopContainerStyle} + desktopFrameStyle={desktopFrameStyle} handleDesktopContentLayout={handleDesktopContentLayout} header={header} stickyHeader={stickyHeader} diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 22dcb1f53..dd75578c2 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -18,7 +18,6 @@ import { Modal, Platform, Pressable, - ScrollView, StatusBar, Text, View, @@ -28,7 +27,7 @@ import { type StyleProp, type ViewStyle, } from "react-native"; -import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CheckCircle } from "lucide-react-native"; @@ -38,6 +37,7 @@ import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; +import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { isWeb, isNative } from "@/constants/platform"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; @@ -498,7 +498,7 @@ export function ContextMenuContent({ [], ); - const animatedContentStyle = useMemo(() => { + const frameStyle = useMemo(() => { const { width: screenWidth } = Dimensions.get("window"); const resolvedWidthStyle: ViewStyle = fullWidth ? { width: screenWidth - horizontalPadding * 2 } @@ -508,7 +508,6 @@ export function ContextMenuContent({ ...(typeof maxWidth === "number" ? { maxWidth } : null), }; return [ - styles.content, resolvedWidthStyle, { position: "absolute" as const, @@ -566,23 +565,24 @@ export function ContextMenuContent({ onPress={handleClose} testID={testID ? `${testID}-backdrop` : undefined} /> - - {children} - - + + ); diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 49c80e7e7..a51f92282 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -14,7 +14,6 @@ import { ActivityIndicator, Modal, Pressable, - ScrollView, Text, View, Dimensions, @@ -25,10 +24,10 @@ import { type ViewStyle, type StyleProp, } from "react-native"; -import Animated, { Keyframe, runOnJS } from "react-native-reanimated"; +import { Keyframe, runOnJS } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; -import { isWeb } from "@/constants/platform"; +import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; @@ -172,42 +171,37 @@ function computePosition({ return { x, y, actualPlacement }; } -interface SharedDropdownContentProps { - collapsable: false; - testID?: string; - style: StyleProp; -} - function renderDropdownSurface(input: { - isWebSurface: boolean; - sharedProps: SharedDropdownContentProps; + frameStyle: StyleProp; + testID?: string; + surfaceStyle: StyleProp; scrollable: boolean; scrollViewportStyle: StyleProp; content: ReactElement; onExited: () => void; }): ReactElement { - const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input; + const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } = + input; const body = scrollable ? ( - {content} - + ) : ( content ); - if (isWebSurface) { - return {body}; - } - return ( - { "worklet"; @@ -217,7 +211,7 @@ function renderDropdownSurface(input: { })} > {body} - + ); } @@ -513,7 +507,8 @@ export function DropdownMenuContent({ [], ); - const contentStyle = useMemo(() => { + const surfaceStyle = styles.content; + const frameStyle = useMemo(() => { const { width: screenWidth } = Dimensions.get("window"); const resolvedWidthStyle: ViewStyle = fullWidth ? { width: screenWidth - horizontalPadding * 2 } @@ -523,7 +518,6 @@ export function DropdownMenuContent({ ...(typeof maxWidth === "number" ? { maxWidth } : null), }; return [ - styles.content, resolvedWidthStyle, { position: "absolute" as const, @@ -543,14 +537,6 @@ export function DropdownMenuContent({ actualPlacement, align, ]); - const sharedContentProps = useMemo( - () => ({ - collapsable: false as const, - testID, - style: contentStyle, - }), - [testID, contentStyle], - ); const scrollViewportStyle = useMemo( () => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null], [visibleContentSize, webScrollbarStyle], @@ -583,8 +569,9 @@ export function DropdownMenuContent({ /> {!closing ? renderDropdownSurface({ - isWebSurface: isWeb, - sharedProps: sharedContentProps, + frameStyle, + testID, + surfaceStyle, scrollable, scrollViewportStyle, content, diff --git a/packages/app/src/components/ui/floating.tsx b/packages/app/src/components/ui/floating.tsx new file mode 100644 index 000000000..4a689d7be --- /dev/null +++ b/packages/app/src/components/ui/floating.tsx @@ -0,0 +1,63 @@ +import { forwardRef, useMemo, type ComponentProps, type ReactElement, type ReactNode } from "react"; +import { + ScrollView, + StyleSheet, + type ScrollViewProps, + type StyleProp, + type View, + type ViewStyle, +} from "react-native"; +import Animated from "react-native-reanimated"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; + +export interface FloatingSurfaceProps extends Omit, "style"> { + frameStyle?: StyleProp; + style?: StyleProp; +} + +export const FloatingSurface = forwardRef(function FloatingSurface( + { frameStyle, style, ...props }, + ref, +): ReactElement { + const inlineFrameStyle = useMemo(() => { + const flattened = StyleSheet.flatten(frameStyle); + return flattened ? inlineUnistylesStyle(flattened) : undefined; + }, [frameStyle]); + const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]); + return ; +}); + +export interface FloatingScrollViewProps { + bounces?: boolean; + children: ReactNode; + contentContainerStyle?: StyleProp; + keyboardShouldPersistTaps?: ScrollViewProps["keyboardShouldPersistTaps"]; + showsVerticalScrollIndicator?: boolean; + style?: StyleProp; +} + +export function FloatingScrollView({ + bounces, + children, + contentContainerStyle, + keyboardShouldPersistTaps, + showsVerticalScrollIndicator, + style, +}: FloatingScrollViewProps): ReactElement { + const inlineStyle = useMemo(() => { + const flattened = StyleSheet.flatten(style); + return flattened ? inlineUnistylesStyle(flattened) : undefined; + }, [style]); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 617c20746..4f403cce2 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -25,9 +25,10 @@ import { } from "react-native"; import { Portal } from "@gorhom/portal"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; -import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { FloatingSurface } from "@/components/ui/floating"; import { isWeb } from "@/constants/platform"; type Side = "top" | "bottom" | "left" | "right"; @@ -494,19 +495,18 @@ export function TooltipContent({ [], ); - const contentStyle = useMemo( + const frameStyle = useMemo( () => [ - styles.content, - { maxWidth }, - style, { position: "absolute" as const, top: position?.y ?? -9999, left: position?.x ?? -9999, + maxWidth, }, ], - [maxWidth, style, position?.x, position?.y], + [maxWidth, position?.x, position?.y], ); + const contentStyle = useMemo(() => [styles.content, style], [style]); const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]); @@ -519,7 +519,7 @@ export function TooltipContent({ return ( - {children} - + ); @@ -544,7 +545,7 @@ export function TooltipContent({ onRequestClose={handleDismiss} > - {children} - + ); diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx index 947e0c64f..caff521bc 100644 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -1,17 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PanResponder, - View, + type GestureResponderEvent, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, + type ViewStyle, + View, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { isWeb as platformIsWeb } from "@/constants/platform"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { computeScrollOffsetFromDragDelta, computeVerticalScrollbarGeometry, } from "./web-desktop-scrollbar.math"; -import { isWeb as platformIsWeb } from "@/constants/platform"; const METRICS_EPSILON = 0.5; const HANDLE_WIDTH_IDLE = 6; @@ -27,6 +30,15 @@ const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240; const HANDLE_SCROLL_VISIBILITY_MS = 1200; const HANDLE_SCROLL_ACTIVE_MS = 110; +interface WebPointerStyle { + cursor?: "grab" | "grabbing"; + touchAction?: "none"; + userSelect?: "none"; + transitionProperty?: string; + transitionDuration?: string; + transitionTimingFunction?: string; +} + interface PointerLikeEvent { clientY?: number; pageY?: number; @@ -59,6 +71,13 @@ function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean { ); } +interface WebDesktopScrollbarOverlayProps { + enabled: boolean; + metrics: ScrollbarMetrics; + onScrollToOffset: (offset: number) => void; + inverted?: boolean; +} + export function useWebDesktopScrollbarMetrics() { const [metrics, setMetrics] = useState({ offset: 0, @@ -115,13 +134,6 @@ export function useWebDesktopScrollbarMetrics() { }; } -interface WebDesktopScrollbarOverlayProps { - enabled: boolean; - metrics: ScrollbarMetrics; - onScrollToOffset: (offset: number) => void; - inverted?: boolean; -} - export function WebDesktopScrollbarOverlay({ enabled, metrics, @@ -273,8 +285,12 @@ export function WebDesktopScrollbarOverlay({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderTerminationRequest: () => false, - onPanResponderGrant: () => { + onPanResponderGrant: (event: GestureResponderEvent) => { + const clientY = readClientY(event); dragStartOffsetRef.current = normalizedOffsetRef.current; + if (clientY !== null) { + dragStartClientYRef.current = clientY; + } setIsDragging(true); }, onPanResponderMove: (_event, gestureState) => { @@ -362,20 +378,19 @@ export function WebDesktopScrollbarOverlay({ const thumbRegionStyle = useMemo( () => [ styles.thumbRegion, - { - top: 0, + inlineUnistylesStyle({ height: thumbRegionHeight, transform: [{ translateY: thumbRegionOffset }], - }, + }), platformIsWeb && - ({ + inlineUnistylesStyle({ cursor: handleCursor, touchAction: "none", userSelect: "none", transitionProperty: "transform", transitionDuration: `${handleTravelDurationMs}ms`, transitionTimingFunction: "linear", - } as object), + } satisfies WebPointerStyle as unknown as ViewStyle), ], [thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs], ); @@ -383,19 +398,19 @@ export function WebDesktopScrollbarOverlay({ const handleStyle = useMemo( () => [ styles.handle, - { + inlineUnistylesStyle({ marginTop: handleInsetTop, height: geometry.handleSize, width: handleWidth, backgroundColor: handleColor, opacity: handleOpacity, - }, + }), platformIsWeb && - ({ + inlineUnistylesStyle({ transitionProperty: "opacity, width, background-color", transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", - } as object), + } satisfies WebPointerStyle as unknown as ViewStyle), ], [handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity], ); @@ -413,8 +428,6 @@ export function WebDesktopScrollbarOverlay({ {...(platformIsWeb ? ({ onPointerDown: startWebDrag, - onPointerEnter: handleGrabHoverIn, - onPointerLeave: handleGrabHoverOut, onMouseEnter: handleGrabHoverIn, onMouseLeave: handleGrabHoverOut, } as object) @@ -446,5 +459,6 @@ const styles = StyleSheet.create(() => ({ position: "absolute", right: -3, width: HANDLE_GRAB_WIDTH, + top: 0, }, })); diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index 1d89935a1..9c32fdd58 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -9,7 +9,7 @@ import { type ReactNode, } from "react"; import { Dimensions, Text, View } from "react-native"; -import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native"; import { GitHubIcon } from "@/components/icons/github-icon"; @@ -24,6 +24,7 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { PrBadge } from "@/components/sidebar-workspace-list"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { FloatingSurface } from "@/components/ui/floating"; import { isWeb } from "@/constants/platform"; interface Rect { @@ -254,23 +255,19 @@ function WorkspaceHoverCardContent({ [], ); - const cardStyle = useMemo( - () => [ - styles.card, - { - width: HOVER_CARD_WIDTH, - position: "absolute" as const, - top: position?.y ?? -9999, - left: position?.x ?? -9999, - }, - ], + const frameStyle = useMemo( + () => ({ + position: "absolute" as const, + top: position?.y ?? -9999, + left: position?.x ?? -9999, + }), [position?.x, position?.y], ); return ( - @@ -303,7 +301,7 @@ function WorkspaceHoverCardContent({ ) : null} - + ); @@ -441,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius.lg, paddingTop: theme.spacing[2], + width: HOVER_CARD_WIDTH, shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index 7fb73a05c..53ba6b98e 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useToast } from "@/contexts/toast-context"; import type { GitAction, GitActions } from "@/git/policy"; @@ -110,18 +111,20 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli const primaryPressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.splitButtonPrimary, - (Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered, + (Boolean(hovered) || pressed) && + inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }), primaryDisabled && styles.splitButtonPrimaryDisabled, ], - [primaryDisabled], + [primaryDisabled, theme.colors.surface2], ); const caretTriggerStyle = useCallback( ({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [ styles.splitButtonCaret, - (hovered || pressed || open) && styles.splitButtonCaretHovered, + (hovered || pressed || open) && + inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }), ], - [], + [theme.colors.surface2], ); return ( @@ -230,9 +233,6 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", position: "relative", }, - splitButtonPrimaryHovered: { - backgroundColor: theme.colors.surface2, - }, splitButtonPrimaryDisabled: { opacity: 0.6, }, @@ -258,9 +258,6 @@ const styles = StyleSheet.create((theme) => ({ borderLeftWidth: theme.borderWidth[1], borderLeftColor: theme.colors.borderAccent, }, - splitButtonCaretHovered: { - backgroundColor: theme.colors.surface2, - }, iconButton: { width: 32, height: 32, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index c312ebdb2..ed9de619e 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -78,6 +78,7 @@ import { lineNumberGutterWidth } from "@/components/code-insets"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import { useGitActions } from "@/git/use-actions"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { @@ -113,25 +114,6 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { return [styles.fileHeader, pressed && styles.fileHeaderPressed]; } -function diffModeTriggerStyle({ - hovered, - pressed, - open, -}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) { - return [ - styles.diffModeTrigger, - Boolean(hovered) && styles.diffModeTriggerHovered, - (pressed || Boolean(open)) && styles.diffModeTriggerPressed, - ]; -} - -function expandAllButtonStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered]; -} - interface HighlightedTextProps { tokens: HighlightToken[]; wrapLines?: boolean; @@ -274,7 +256,12 @@ function DiffGutterCell({ style?: StyleProp; }) { const containerStyle = useMemo( - () => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }, style], + () => [ + styles.gutterCell, + lineTypeBackground(type), + inlineUnistylesStyle({ width: gutterWidth }), + style, + ], [type, gutterWidth, style], ); const textStyle = useMemo( @@ -544,7 +531,10 @@ function InlineReviewThreadContent({ }) { const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions }); const height = reservedHeight ?? threadState?.height ?? 0; - const placeholderStyle = useMemo(() => ({ minHeight: height }), [height]); + const placeholderStyle = useMemo( + () => inlineUnistylesStyle({ minHeight: height }), + [height], + ); if (height === 0) { return null; } @@ -580,7 +570,11 @@ function InlineReviewGutterSpacer({ const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions }); const height = reservedHeight ?? threadState?.height ?? 0; const spacerStyle = useMemo>( - () => [styles.inlineReviewGutterSpacer, { width: gutterWidth, minHeight: height }, style], + () => [ + styles.inlineReviewGutterSpacer, + inlineUnistylesStyle({ width: gutterWidth, minHeight: height }), + style, + ], [gutterWidth, height, style], ); if (height === 0) { @@ -604,10 +598,13 @@ function InlineReviewRow({ const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions }); const height = reservedHeight ?? threadState?.height ?? 0; const gutterSpacerStyle = useMemo>( - () => [styles.inlineReviewGutterSpacer, { width: gutterWidth }], + () => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })], [gutterWidth], ); - const placeholderStyle = useMemo(() => ({ minHeight: height }), [height]); + const placeholderStyle = useMemo( + () => inlineUnistylesStyle({ minHeight: height }), + [height], + ); if (height === 0) { return null; } @@ -656,7 +653,10 @@ function SplitDiffColumn({ [showDivider], ); const linesContainerRowStyle = useMemo( - () => [styles.linesContainer, scrollWidth > 0 && { minWidth: scrollWidth }], + () => [ + styles.linesContainer, + scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }), + ], [scrollWidth], ); @@ -916,7 +916,10 @@ function DiffFileBody({ const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; const linesContainerRowStyle = useMemo( - () => [styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }], + () => [ + styles.linesContainer, + availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }), + ], [availableWidth], ); @@ -1062,7 +1065,7 @@ interface GitDiffPaneProps { } type PressableStyleFn = ( - state: PressableStateCallbackType & { hovered?: boolean }, + state: PressableStateCallbackType & { hovered?: boolean; open?: boolean }, ) => StyleProp; interface DiffLayoutToggleGroupProps { @@ -1436,14 +1439,30 @@ function computePrErrorMessage( return prPayloadError?.message ?? null; } +function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn { + return ({ hovered, pressed, open }) => [ + styles.diffModeTrigger, + (Boolean(hovered) || pressed || Boolean(open)) && + inlineUnistylesStyle({ backgroundColor: surfaceColor }), + ]; +} + +function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn { + return ({ hovered, pressed }) => [ + styles.expandAllButton, + (Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }), + ]; +} + function buildToggleButtonStyle( selected: boolean, baseStyles: StyleProp | StyleProp[], + surfaceColor: string, ): PressableStyleFn { return ({ hovered, pressed }) => [ baseStyles, - selected && styles.toggleButtonSelected, - (Boolean(hovered) || pressed) && styles.diffStatusRowHovered, + (selected || Boolean(hovered) || pressed) && + inlineUnistylesStyle({ backgroundColor: surfaceColor }), ]; } @@ -1494,32 +1513,50 @@ export function GitDiffPane({ handleLayoutChange("split"); }, [handleLayoutChange]); + const controlSurfaceColor = theme.colors.surface2; + const diffModeTriggerStyle = useMemo( + () => buildDiffModeTriggerStyle(controlSurfaceColor), + [controlSurfaceColor], + ); + const unifiedToggleStyle = useMemo( () => - buildToggleButtonStyle(changesPreferences.layout === "unified", [ - styles.toggleButton, - styles.toggleButtonGroupStart, - ]), - [changesPreferences.layout], + buildToggleButtonStyle( + changesPreferences.layout === "unified", + [styles.toggleButton, styles.toggleButtonGroupStart], + controlSurfaceColor, + ), + [changesPreferences.layout, controlSurfaceColor], ); const splitToggleStyle = useMemo( () => - buildToggleButtonStyle(changesPreferences.layout === "split", [ - styles.toggleButton, - styles.toggleButtonGroupEnd, - ]), - [changesPreferences.layout], + buildToggleButtonStyle( + changesPreferences.layout === "split", + [styles.toggleButton, styles.toggleButtonGroupEnd], + controlSurfaceColor, + ), + [changesPreferences.layout, controlSurfaceColor], ); const hideWhitespaceToggleStyle = useMemo( - () => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton), - [changesPreferences.hideWhitespace], + () => + buildToggleButtonStyle( + changesPreferences.hideWhitespace, + styles.expandAllButton, + controlSurfaceColor, + ), + [changesPreferences.hideWhitespace, controlSurfaceColor], ); const wrapLinesToggleStyle = useMemo( - () => buildToggleButtonStyle(wrapLines, styles.expandAllButton), - [wrapLines], + () => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor), + [wrapLines, controlSurfaceColor], + ); + + const expandAllToggleStyle = useMemo( + () => buildExpandAllButtonStyle(controlSurfaceColor), + [controlSurfaceColor], ); const { @@ -2046,7 +2083,7 @@ export function GitDiffPane({ allExpanded={allExpanded} isMobile={isMobile} wrapLinesToggleStyle={wrapLinesToggleStyle} - expandAllToggleStyle={expandAllButtonStyle} + expandAllToggleStyle={expandAllToggleStyle} onToggleWrapLines={handleToggleWrapLines} onToggleExpandAll={handleToggleExpandAll} /> diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx index 13e59f579..f773e63d4 100644 --- a/packages/app/src/review/surface.test.tsx +++ b/packages/app/src/review/surface.test.tsx @@ -3,6 +3,7 @@ import "@/test/window-local-storage"; import { act, fireEvent, render, renderHook, cleanup } from "@testing-library/react"; import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { useReviewDraftStore, type ReviewDraftComment } from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { @@ -275,7 +276,7 @@ describe("git diff inline review helpers", () => { viewportWidth: 320, pinToViewport: true, }), - ).toEqual([{ position: "sticky", left: 0 }, { width: 320 }]); + ).toEqual([{ position: "sticky", left: 0 }, inlineUnistylesStyle({ width: 320 })]); }); it("keeps the gutter add-comment target accessible and clicking opens the editor", () => { diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index d07133c0a..1ea505e38 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -15,6 +15,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; import { Shortcut } from "@/components/ui/shortcut"; import { isWeb } from "@/constants/platform"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useWorkspaceFocusRestoration } from "@/workspace/focus"; import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; @@ -391,7 +392,7 @@ export function InlineReviewThread({ () => [ styles.threadContainer, getInlineReviewThreadViewportStyle({ viewportWidth, pinToViewport }), - { minHeight: height }, + inlineUnistylesStyle({ minHeight: height }), ], [viewportWidth, pinToViewport, height], ); @@ -478,7 +479,8 @@ export function getInlineReviewThreadViewportStyle({ viewportWidth?: number; pinToViewport: boolean; }): StyleProp { - const widthStyle = viewportWidth && viewportWidth > 0 ? { width: viewportWidth } : null; + const widthStyle = + viewportWidth && viewportWidth > 0 ? inlineUnistylesStyle({ width: viewportWidth }) : null; if (!pinToViewport || !isWeb) { return widthStyle; } diff --git a/packages/app/src/styles/unistyles-inline-style.ts b/packages/app/src/styles/unistyles-inline-style.ts new file mode 100644 index 000000000..4201c2b0d --- /dev/null +++ b/packages/app/src/styles/unistyles-inline-style.ts @@ -0,0 +1,23 @@ +const UNISTYLES_INLINE_STYLE_KEY = "unistyles_inline_style"; + +/** + * Forces a style object through Unistyles' inline/animated-style lane. + * + * Unistyles web sends ordinary style objects to the CSS registry. Styles that + * look like animated styles stay in React Native's style array instead. Use + * this only for high-churn values, such as measured dimensions, drag + * transforms, and pressed/hovered state. + */ +export function inlineUnistylesStyle(style: TStyle): TStyle { + if (!Object.isExtensible(style) || UNISTYLES_INLINE_STYLE_KEY in style) { + return style; + } + + Object.defineProperty(style, UNISTYLES_INLINE_STYLE_KEY, { + value: {}, + enumerable: true, + configurable: true, + }); + + return style; +} From 18a1bdcf72114441c6b8db76c47109db0351ed3e Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 08:57:53 +0000 Subject: [PATCH 7/7] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 1d826ced4..1a51684db 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-vNhmXjWV1wf17JHVX6tpTz4CKeCE7hpyBLj8nzPVUH0= +sha256-O1Rxdwx+4XczJyz+UqVF4mOSSRSmzoCCMPNFrkQriWc=