From 9ea9c3d5d2c444133e3ddff40eecf38737951589 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:42:17 +0200 Subject: [PATCH 001/111] fix(web): keep a file-to-symlink type change from crashing the diff view (#11075) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../components/diffs/DiffFileTree.test.tsx | 19 ++++++++++ .../diffs/diffFileTree.logic.test.ts | 17 +++++++++ .../components/diffs/diffFileTree.logic.ts | 15 +++++++- apps/web/src/lib/diffRendering.test.ts | 37 ++++++++++++++++--- apps/web/src/lib/diffRendering.ts | 7 +++- 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/diffs/DiffFileTree.test.tsx b/apps/web/src/components/diffs/DiffFileTree.test.tsx index d7767ab94258..cd8f4d5b3222 100644 --- a/apps/web/src/components/diffs/DiffFileTree.test.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.test.tsx @@ -5,7 +5,10 @@ import { act, type MouseEvent, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { FileDiffMetadata } from "@pierre/diffs"; + import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree"; +import { diffFileTreeEntries } from "./diffFileTree.logic"; import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); @@ -112,6 +115,22 @@ describe("diff tree file activation", () => { vi.unstubAllGlobals(); }); + it("mounts a file-to-symlink type change as one tree row", async () => { + // Git carries a type change as a deletion and an addition of the same path. + const typeChange = diffFileTreeEntries([ + { type: "deleted", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata, + { type: "new", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata, + { type: "change", name: "CLAUDE.md", prevName: "CLAUDE.md" } as FileDiffMetadata, + ]); + await mount({ files: [...typeChange] }); + + const tree = model(); + expect(tree.getItem("AGENTS.md")?.isDirectory()).toBe(false); + expect(tree.getItem("CLAUDE.md")?.isDirectory()).toBe(false); + await activate("AGENTS.md"); + expect(targets).toEqual([{ type: "item", id: "AGENTS.md\u0000AGENTS.md", align: "start" }]); + }); + it("reissues the reveal when the sole selected file is activated again", async () => { await mount(); await activate("02-short.ts"); diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts index 4aa1fd7208b1..170e84308069 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.test.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts @@ -34,6 +34,23 @@ describe("diffFileTreeEntries", () => { }); }); +describe("diffFileTreeEntries", () => { + it("folds a file-to-symlink type change into one modified entry", () => { + expect( + diffFileTreeEntries([ + file("change", "CLAUDE.md"), + file("deleted", "AGENTS.md"), + file("new", "AGENTS.md"), + file("new", "docs/new.md"), + ]), + ).toEqual([ + { path: "CLAUDE.md", status: "modified" }, + { path: "AGENTS.md", status: "modified" }, + { path: "docs/new.md", status: "added" }, + ]); + }); +}); + describe("collectDirectoryPaths", () => { it("lists every ancestor once, parents first, with Pierre's trailing slash", () => { expect(collectDirectoryPaths(["apps/web/src/a.ts", "apps/web/b.ts", "README.md"])).toEqual([ diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index 7331fb150e05..b727c3ee3ffb 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -23,11 +23,22 @@ function toGitStatus(file: FileDiffMetadata): GitStatus { } } -/** Maps parsed diff files to tree entries, keeping the diff's own order. */ +/** + * Maps parsed diff files to tree entries, keeping the diff's own order. A path + * appears once: a type change (regular file to symlink) is a deletion plus an + * addition of the same path, and the tree shows the surviving file as modified. + */ export function diffFileTreeEntries( files: ReadonlyArray, ): ReadonlyArray { - return files.map((file) => ({ path: resolveFileDiffPath(file), status: toGitStatus(file) })); + const statusByPath = new Map(); + for (const file of files) { + const path = resolveFileDiffPath(file); + const status = toGitStatus(file); + const previous = statusByPath.get(path); + statusByPath.set(path, previous === undefined || previous === status ? status : "modified"); + } + return [...statusByPath].map(([path, status]) => ({ path, status })); } /** diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 4f6746942af2..2f98aac50635 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -35,10 +35,10 @@ describe("buildPatchCacheKey", () => { describe("getRenderablePatch", () => { it.each([ - ["a/example.ts", "a/example.ts"], - ["b/example.ts", "b/example.ts"], - ["a/before.ts", "b/after.ts"], - ])("preserves repository paths from %s to %s", (previousPath, path) => { + ["a/example.ts", "a/example.ts", "change"], + ["b/example.ts", "b/example.ts", "change"], + ["a/before.ts", "b/after.ts", "rename-changed"], + ])("preserves repository paths from %s to %s", (previousPath, path, type) => { const parsed = getRenderablePatch( [ `diff --git a/${previousPath} b/${path}`, @@ -59,7 +59,7 @@ describe("getRenderablePatch", () => { if (!file) return; expect(resolveFileDiffPath(file)).toBe(path); expect(resolveFileDiffPreviousPath(file)).toBe(previousPath); - expect(buildFileDiffIdentityKey(file)).toBe(`${previousPath}\0${path}`); + expect(buildFileDiffIdentityKey(file)).toBe(`${previousPath}\0${path}\0${type}`); }); it("compacts partial hunk render offsets for virtualized review diffs", () => { @@ -137,6 +137,33 @@ describe("diff file reconciliation", () => { expect(buildFileDiffRenderKey(file)).toBe(key); }); + it("gives a type change its own identity per block", () => { + const patch = [ + "diff --git a/AGENTS.md b/AGENTS.md", + "deleted file mode 100644", + "--- a/AGENTS.md", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-duplicated instructions", + "diff --git a/AGENTS.md b/AGENTS.md", + "new file mode 120000", + "--- /dev/null", + "+++ b/AGENTS.md", + "@@ -0,0 +1 @@", + "+CLAUDE.md", + ].join("\n"); + const parsed = getRenderablePatch(patch, "type-change"); + expect(parsed?.kind).toBe("files"); + if (parsed?.kind !== "files") return; + const [deleted, added] = parsed.files; + expect(deleted?.type).toBe("deleted"); + expect(added?.type).toBe("new"); + if (!deleted || !added) return; + + expect(buildFileDiffIdentityKey(deleted)).not.toBe(buildFileDiffIdentityKey(added)); + expect(new Set(parsed.files.map(buildFileDiffIdentityKey)).size).toBe(parsed.files.length); + }); + it("keeps identities stable and versions local to the changed file", () => { const patch = (secondLine: string) => [ diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 257b4e7f8d6d..3a6c6e20969a 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -166,8 +166,13 @@ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? ""); } +/** + * Stable across re-renders of the same file, distinct for every block in a + * patch. A type change (regular file to symlink) arrives as a deletion and an + * addition of the same path, so the change type is part of the identity. + */ export function buildFileDiffIdentityKey(fileDiff: FileDiffMetadata): string { - return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}`; + return `${resolveFileDiffPreviousPath(fileDiff)}\u0000${resolveFileDiffPath(fileDiff)}\u0000${fileDiff.type}`; } export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { From ccad9f69aae7465c4ad4ea169d273f61ffac1eb5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 10:38:09 -0700 Subject: [PATCH 002/111] Use T3 Device panel for mobile testing (#12414) --- .agents/skills/ios-debugger-agent/LICENSE | 21 -- .agents/skills/ios-debugger-agent/SKILL.md | 66 ------ .../ios-debugger-agent/agents/openai.yaml | 9 - .agents/skills/ios-simulator-browser/LICENSE | 21 -- .agents/skills/ios-simulator-browser/SKILL.md | 51 ----- .../ios-simulator-browser/agents/openai.yaml | 4 - .agents/skills/test-t3-app/SKILL.md | 105 +++------ .agents/skills/test-t3-app/agents/openai.yaml | 4 +- .agents/skills/test-t3-mobile/SKILL.md | 203 ++++-------------- .../skills/test-t3-mobile/agents/openai.yaml | 4 +- .../test-t3-mobile/scripts/pair-client.sh | 49 ++--- .codex/config.toml | 9 - .mcp.json | 11 - 13 files changed, 86 insertions(+), 471 deletions(-) delete mode 100644 .agents/skills/ios-debugger-agent/LICENSE delete mode 100644 .agents/skills/ios-debugger-agent/SKILL.md delete mode 100644 .agents/skills/ios-debugger-agent/agents/openai.yaml delete mode 100644 .agents/skills/ios-simulator-browser/LICENSE delete mode 100644 .agents/skills/ios-simulator-browser/SKILL.md delete mode 100644 .agents/skills/ios-simulator-browser/agents/openai.yaml delete mode 100644 .codex/config.toml delete mode 100644 .mcp.json diff --git a/.agents/skills/ios-debugger-agent/LICENSE b/.agents/skills/ios-debugger-agent/LICENSE deleted file mode 100644 index 0d193abe04f2..000000000000 --- a/.agents/skills/ios-debugger-agent/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) OpenAI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md deleted file mode 100644 index 8d204ff201bc..000000000000 --- a/.agents/skills/ios-debugger-agent/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: ios-debugger-agent -description: Build, launch, inspect, and drive iOS apps with the repository-configured XcodeBuildMCP server. Use on macOS for iOS Simulator builds, focused native test runs, semantic UI automation, screenshots, logs, or debugging, including T3 Code Mobile verification. ---- - -# iOS Debugger Agent - -Use the repository-configured `xcodebuildmcp` tools instead of requiring a globally installed Codex plugin. Prefer MCP tools over raw `xcodebuild`, `xcrun`, or `simctl` when the client exposes them. - -## Confirm availability - -This workflow requires macOS 14.5 or newer, Xcode 16 or newer, and Node.js 18 or newer. The repository pins XcodeBuildMCP in both `.mcp.json` for Claude Code and `.codex/config.toml` for Codex. Project MCP servers may require one-time trust or approval followed by a new session. - -If the tools are missing: - -1. Confirm the repository is trusted and its project MCP server was approved. -2. Restart or recreate the agent session after approving configuration. -3. Run `npx --yes xcodebuildmcp@2.6.2 doctor` when the server starts but simulator or UI-automation tools are unavailable. Follow its actionable Xcode or AXe setup guidance. -4. Fall back to the pinned XcodeBuildMCP CLI or native Apple CLIs only when the current agent client cannot expose project MCP tools. - -Do not ask contributors to install the OpenAI `build-ios-apps` plugin globally. - -## Establish one simulator context - -1. Call `session_show_defaults` before discovery, build, launch, or UI work. -2. Call `list_sims` and select one explicit simulator UDID. Prefer a simulator that is already booted; boot an installed simulator when verification requires it, but do not create or download runtimes without user authorization. -3. Call `session_set_defaults` with the project or workspace, scheme, Debug configuration, simulator ID, and bundle identifier when known. -4. Keep every subsequent build, launch, screenshot, log capture, and UI action pinned to that same UDID. - -Avoid generic Mac window automation for switching among Simulator windows. Explicit device identity is more reliable. - -## Choose build or launch - -For T3 Code Mobile, run `node scripts/mobile-native-client.ts ensure ios ` from the checkout on the simulator host first. It checks the local Expo native fingerprint against the installed client and builds/installs when stale, missing, or unknown. Then launch with the intended Metro bundle. Authorized verification includes native builds and installs; do not stop because the existing client is old. Use `check` instead of `ensure` only when the user explicitly prohibits rebuilding or requests a read-only check. - -- Use `build_run_sim` when native source, native dependencies, entitlements, or project configuration changed. -- Use `test_sim` for the smallest relevant native test target or test cases; do not run an entire workspace test matrix routinely. -- Use `launch_app_sim` when a compatible app is already installed and no native rebuild is needed. -- To reuse an existing build artifact, use `get_sim_app_path` or `get_app_bundle_id`, install it with `install_app_sim` when necessary, and then launch it. -- Do not run a build-only action immediately before `build_run_sim` unless the task requires both artifacts. - -After launch, call `snapshot_ui` or `screenshot` before interacting. An open Simulator window alone is not evidence that the intended app launched. - -## Drive the UI semantically - -1. Call `snapshot_ui` to obtain the current accessibility hierarchy and element references. -2. Use only current `elementRef` values whose snapshot entries list the intended action. XcodeBuildMCP `2.6.2` does not accept coordinates for `tap`; when the app exposes no actionable reference, prefer a registered deep link or another app-supported route and otherwise report the accessibility blocker. -3. Refresh with `snapshot_ui` after navigation or layout changes. Element references are snapshot-specific. -4. Use `wait_for_ui` for asynchronous transitions when available rather than fixed sleeps. -5. Capture a final `screenshot` for the state that proves the affected flow. - -Use `gesture` or scoped swipe actions when needed. If a gesture is unreliable, return to a known route or relaunch rather than switching to generic desktop automation. - -## Capture logs and debug - -- Use `start_sim_log_cap` and `stop_sim_log_cap` with the exact bundle identifier for focused runtime logs. -- Use debugger tools only when the task requires runtime diagnosis; attach to the selected simulator and app rather than an ambiguous process. -- Summarize relevant errors instead of returning unbounded logs. - -## Clean up - -Stop only log captures, debugger sessions, apps, or simulators started for the current test. Leave pre-existing simulators and unrelated sessions alone. - -## Upstream - -Adapted from OpenAI's [`build-ios-apps`](https://github.com/openai/plugins/tree/main/plugins/build-ios-apps) plugin version `0.1.2` (`ios-debugger-agent`, MIT) and aligned with XcodeBuildMCP `2.6.2` tool names. diff --git a/.agents/skills/ios-debugger-agent/agents/openai.yaml b/.agents/skills/ios-debugger-agent/agents/openai.yaml deleted file mode 100644 index 094bae6620be..000000000000 --- a/.agents/skills/ios-debugger-agent/agents/openai.yaml +++ /dev/null @@ -1,9 +0,0 @@ -interface: - display_name: "iOS Debugger Agent" - short_description: "Build and drive iOS Simulator apps" - default_prompt: "Use $ios-debugger-agent to build, launch, and inspect the current iOS app on Simulator." -dependencies: - tools: - - type: "mcp" - value: "xcodebuildmcp" - description: "Repository-configured Xcode build, simulator, logging, debugging, and semantic UI tools" diff --git a/.agents/skills/ios-simulator-browser/LICENSE b/.agents/skills/ios-simulator-browser/LICENSE deleted file mode 100644 index 0d193abe04f2..000000000000 --- a/.agents/skills/ios-simulator-browser/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) OpenAI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.agents/skills/ios-simulator-browser/SKILL.md b/.agents/skills/ios-simulator-browser/SKILL.md deleted file mode 100644 index 74b97530799c..000000000000 --- a/.agents/skills/ios-simulator-browser/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: ios-simulator-browser -description: Stream an explicit iOS Simulator through pinned serve-sim into the T3 Code in-app browser or another agent browser. Use on Apple Silicon macOS when the user should watch simulator verification live or when browser-visible simulator evidence is needed. ---- - -# iOS Simulator Browser - -Use serve-sim as the shared visual feed for an iOS Simulator. Use `ios-debugger-agent` and XcodeBuildMCP semantic UI tools to drive the app; do not treat browser-canvas coordinates as a substitute for missing app accessibility. - -## Confirm availability - -serve-sim `0.1.45` requires Apple Silicon macOS, Xcode command-line tools, and Node.js 20 or newer. If the host is unsupported, continue with XcodeBuildMCP screenshots and report that live streaming was unavailable. - -When running inside T3 Code, use its product-native browser MCP to open the stream. Other agent hosts may use their own browser or preview surface. - -Keep serve-sim on its default `127.0.0.1` binding. Do not expose its preview to a LAN or tunnel unless the user explicitly requests that access and the network is trusted; the preview includes a token-gated shell-execution route. - -## Start one owned stream - -1. Obtain the exact simulator UDID from the iOS build or launch workflow. -2. Check whether an existing serve-sim stream for that UDID belongs to another task. Reuse it only when explicitly shared; never kill another task's stream. -3. Otherwise, clear only a stale stream for that UDID and start the pinned version with scoped cleanup: - - ```bash - SIMULATOR_ID= - cleanup_serve_sim() { - npx --yes serve-sim@0.1.45 --kill "$SIMULATOR_ID" >/dev/null 2>&1 || true - } - trap cleanup_serve_sim EXIT INT TERM HUP - cleanup_serve_sim - npx --yes serve-sim@0.1.45 "$SIMULATOR_ID" - ``` - -4. Keep the terminal alive and open the exact local URL printed by serve-sim in the agent's browser. -5. Verify that a live simulator frame renders. A loaded wrapper page is not sufficient evidence. - -## Observe while driving semantically - -- Let the user watch the serve-sim stream while XcodeBuildMCP performs `snapshot_ui`, semantic taps, typing, gestures, and screenshots. -- Keep the browser and Xcode tooling pinned to the same simulator UDID. -- Do not switch to generic desktop automation or browser-canvas clicking merely because the stream is visible. - -If the in-app browser explicitly reports that previews are unavailable, do not install unrelated browser automation. Continue through XcodeBuildMCP, capture a simulator screenshot, report the unavailable live stream, and clean up the owned serve-sim process. - -## Finish - -Stop the long-running terminal and wait for its cleanup trap to finish. If it disappeared without cleanup, run `npx --yes serve-sim@0.1.45 --kill ` for that exact simulator. Never run an unscoped `--kill`. - -## Upstream - -Adapted from OpenAI's [`build-ios-apps`](https://github.com/openai/plugins/tree/main/plugins/build-ios-apps) plugin version `0.1.2` (`ios-simulator-browser`, MIT). It invokes serve-sim `0.1.45` under its Apache-2.0 license without vendoring the package. diff --git a/.agents/skills/ios-simulator-browser/agents/openai.yaml b/.agents/skills/ios-simulator-browser/agents/openai.yaml deleted file mode 100644 index 9e8cdf7a16b0..000000000000 --- a/.agents/skills/ios-simulator-browser/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "iOS Simulator Browser" - short_description: "Stream iOS Simulator in the browser" - default_prompt: "Use $ios-simulator-browser to stream the selected iOS Simulator into the T3 Code in-app browser." diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 0e11b50e1c83..06fe9867b15a 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -1,90 +1,41 @@ --- name: test-t3-app -description: Launch, retain, and test the T3 Code web app in isolated development environments, including first-try browser authentication with one-time pairing URLs, pairing-token recovery, worktree-safe state directories, cross-turn dev server lifecycle, and direct SQLite inspection or fixture seeding. Use when an agent needs to run T3 locally, iteratively test UI behavior with a human, recover from an expired or consumed pairing token, isolate dev state, or prepare test data in state.sqlite. +description: Test T3 Code's web and desktop UI through its built-in Browser panel against isolated development state. Use for browser verification, browser pairing recovery, and test fixtures. Use test-t3-mobile for native mobile verification. --- -# Test T3 App +# Test T3 web and desktop -Use this skill for the web client. For iOS Simulator, Android Emulator, or physical-device testing against an isolated T3 backend, use the sibling [`test-t3-mobile`](../test-t3-mobile/SKILL.md) skill. +Use T3's built-in Browser panel for verification. If its tools are absent or +the panel reports unavailable, explain the blocker and stop verification. +Do not install or switch to another automation system. For native mobile +testing, use [test-t3-mobile](../test-t3-mobile/SKILL.md). -## Start an isolated web environment +## Start the app -1. Run commands from the repository root. -2. Choose a base directory that belongs only to the current worktree or test: - - Use the repository's ignored `.t3` directory for reusable worktree-local state. - - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path. -3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir ` only when the test needs a different isolated directory. -4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output. +Reuse this task's healthy dev server. Otherwise run `vp run dev` from the +repository root and retain its terminal session. Use the worktree's ignored +`.t3` state and read the actual ports and pairing URL from the dev-runner output. +Never run against `~/.t3/userdata` or set `VITE_HTTP_URL` or `VITE_WS_URL`. -Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership. +Test with meaningful project and thread data. Read +[references/sqlite-fixtures.md](references/sqlite-fixtures.md) only when +inspecting or seeding SQLite. Stop the test server before direct fixture writes. -The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server. +## Use the Browser panel -Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line. +Call `preview_status`, then `preview_open` if the Browser panel is +closed. Navigate to the complete startup pairing URL once with +`preview_navigate`, then use `preview_snapshot` and T3's interaction tools. +If the token was consumed or expired, run `node apps/server/src/bin.ts pair` +for a fresh one. Keep using the same tab. -Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. +## Verify and retain -The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it. +Exercise the affected flow and capture the state that proves it works. Keep +the server, state, and panel available while the user inspects or iterates. +An assistant turn ending is not teardown. Stop only processes you started, +using retained terminal sessions or captured PIDs. -### Verify a shared environment before human handoff - -When another person will use the printed pairing URL, first open the shared origin without the pairing path or fragment in the controlled browser and confirm the T3 Code app loads. This browser navigation is required even when curl succeeds because browsers block some otherwise reachable ports before making a network request. - -Do not open the other person's complete pairing URL during this reachability check; doing so consumes its one-time token. If the agent also needs an authenticated browser, create and consume a separate pairing token, then leave a fresh token for the other person. - -## Preserve the environment while iterating - -Treat the overall testing or implementation loop—not an assistant turn or one verification pass—as the environment lifecycle boundary. - -- Keep the dev process, base directory, selected ports, authenticated browser tab, registered projects, and seeded fixtures alive while the user may inspect the result or request follow-up changes. -- Do not stop the server merely because one verification pass completed or because you are yielding a response to the user. -- Before starting another environment, check whether the existing process and browser tab still serve the task. Reuse them when healthy instead of discarding useful state. -- On a later turn, verify that the existing process is alive and reuse its printed ports and base directory. If it exited, restart with the same base directory; create a new pairing token only when the browser session is no longer valid. -- Tell the user when a test environment remains available, including its non-secret web URL when useful. Include a pairing token only when the user still needs to pair (see below). - -## Authenticate the browser on the first navigation - -1. Wait for the server log that says authentication is required and includes a URL ending in `/pair#token=...`. -2. Use the controlled in-app browser or browser-automation surface available to the agent. Do not use a system-browser launch command during automated testing. -3. Open that complete URL exactly once as the controlled browser's first navigation. Preserve the fragment and token verbatim. -4. Wait for the pairing exchange and redirect to finish before navigating elsewhere. -5. Continue in the same browser context so its stored bearer session remains available. - -Keep pairing URLs out of screenshots, committed files, and durable logs. When the user asked for a shared environment, the deliverable IS the full pairing URL — paste it in your reply, token and all; a bare origin is useless to them. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it, so never open a URL you handed to the user. - -## Recover a consumed or expired pairing token - -Run `node apps/server/src/bin.ts pair` from the repository root. It discovers the running dev server (worktree `.t3` first, same precedence as the dev runner) and prints a fresh `Pair URL` against the server's current web origin, including a `--share` tailnet origin. Pass `--base-dir ` only when the server was started with `--home-dir`, using the identical path. - -Tokens from `pair` carry standard client scopes. The startup pairing URL carries admin scopes; if the user needs Settings → Connections management (`access:write`), restart the server and hand over the new startup URL instead. - -## Inspect or seed SQLite state - -Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before changing the database. - -- Use `node apps/server/scripts/t3-sqlite-state.ts query` for schema discovery and read-only checks. -- Stop the dev server before using `node apps/server/scripts/t3-sqlite-state.ts exec`, then restart it with the same base directory. -- Seed projection tables only for disposable UI fixtures. Use application commands and APIs when testing business behavior or projection correctness. -- Use the auth CLI, not direct `auth_*` table edits, for pairing and sessions. - -The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation. - -## Tear down only when the testing loop is finished - -Tear down when the user explicitly asks, confirms the iteration is finished, or the overall task is genuinely complete with no pending human review. Do not infer completion from the end of an assistant turn. - -When teardown is appropriate: - -1. Stop the dev process with its terminal interrupt. -2. Preserve the isolated base directory when it contains useful reproduction evidence or state for a likely follow-up. -3. Otherwise remove only a path created for this test after resolving and verifying the exact target. - -If completion is uncertain, keep the environment alive and mention that it is retained for further iteration. A fresh isolated base directory remains the safest reset when authentication, migrations, or fixture state becomes ambiguous. - -## Troubleshoot predictably - -- If the browser shows an unauthenticated pairing screen, issue a new token instead of retrying the consumed URL. -- If the pairing URL is no longer visible, create a replacement token with both `--dev-url` and `--base-url`. -- If the replacement token is rejected, verify that the CLI and server use the identical absolute base directory and web URL. -- If the UI shows unexpected data, verify that every command uses the identical explicit base directory before editing anything. -- If ports move because another instance is running, trust the current dev-runner output rather than assuming ports `13773` and `5733`. +When sharing is requested, start with `vp run dev --share` and give the user +a fresh complete pairing URL that you have not consumed. Keep other credentials +out of screenshots, commits, and replies. diff --git a/.agents/skills/test-t3-app/agents/openai.yaml b/.agents/skills/test-t3-app/agents/openai.yaml index 0445ee4cb9ec..2f161a27ee0b 100644 --- a/.agents/skills/test-t3-app/agents/openai.yaml +++ b/.agents/skills/test-t3-app/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "T3 App Testing" - short_description: "Launch and retain isolated T3 test environments" - default_prompt: "Use $test-t3-app to launch an isolated T3 environment and iteratively test it in the browser while preserving state." + short_description: "Test web and desktop in T3's Browser panel" + default_prompt: "Use $test-t3-app to verify the affected web or desktop flow through T3's Browser panel." diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index afebcb7aa7d5..1222449cd8a6 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -1,190 +1,65 @@ --- name: test-t3-mobile -description: Launch and test T3 Code Mobile on an iOS Simulator or Android Emulator against disposable local T3 environments, including Metro and dev-client reuse, native rebuild decisions, per-client pairing, seeded projects, semantic UI control, screenshots, and iOS serve-sim streaming. Use after mobile UI or native changes, when reproducing phone or tablet behavior, pairing an emulator to isolated state, or verifying mobile behavior on macOS, Linux, or Windows. +description: Test T3 Code's native iOS and Android app through its Device panel and returned AgentDevice command. Use for mobile verification, native-client builds, Metro launch, and mobile pairing against isolated development state. --- # Test T3 Mobile -Run one focused, end-to-end mobile verification pass against disposable T3 state. Use the sibling [`test-t3-app`](../test-t3-app/SKILL.md) skill as the detailed reference for pairing-token semantics and SQLite fixtures. +## Open the device -Command examples use POSIX shell syntax. On Windows, use PowerShell equivalents: set variables with `$env:NAME = "value"`, use an explicit temporary directory from `[System.IO.Path]::GetTempPath()`, and run multiline examples on one line or with PowerShell backticks. Use `$env:ANDROID_HOME\platform-tools\adb.exe` when `adb` is not already on `PATH`. +Call `device_list`, then `device_open` with the selected host and device IDs. +T3 boots the device and shows its live stream in the Device panel. Follow its +returned `quickStart`, using the exact `agentDevice.command` and all `targetArgs` +on every operation. Use `device_screenshot` to inspect the screen. -## Select a viable platform +If T3 device tools or the selected device are unavailable, report the blocker +and stop verification. Do not install or switch to another automation system. -Inspect the host and the affected code before launching processes: +## Use an isolated backend -- On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available. -- On macOS, Linux, or Windows with the Android SDK, use one Android Emulator when Android is the affected surface or iOS tooling is unavailable. -- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK or emulator prerequisite rather than claiming verification. A missing development client is a build step, not a blocker. +Reuse this task's healthy backend. Otherwise run `vp run dev` from the +repository root, retain its terminal session, and read the actual backend port +from the dev-runner output. Use the worktree's ignored `.t3` state. Never run +against `~/.t3/userdata`. The Browser panel is not required for this workflow. -Do not treat unavailable iOS tooling as a blocker when Android is a valid representative target. +Test with meaningful project and thread data. Read the shared +[SQLite fixture reference](../test-t3-app/references/sqlite-fixtures.md) only +when inspecting or seeding SQLite. Stop the test server before fixture writes. -## Ensure a compatible native client +## Launch T3 Code Dev -Authorized mobile verification includes building and installing a development client. A missing, stale, or unknown native client is not a reason to skip verification or leave a PR in draft. Build and install it, then continue. Respect an explicit user instruction not to rebuild; otherwise do not ask for separate permission. - -Run this from the checkout being tested, on the machine that hosts the selected simulator or emulator. Select and boot one explicit iOS UDID or Android emulator serial first: - -```bash -node scripts/mobile-native-client.ts ensure ios -node scripts/mobile-native-client.ts ensure android -``` - -`ensure` compares the checkout's local Expo development fingerprint and the installed app's binary contents against the last successful build record. It reuses a matching client; otherwise it runs a clean prebuild, builds and installs the development app, and records the successful result. It does not start Metro. Start Metro below after it succeeds. On hosts with an `agent-job` requirement, run the entire `ensure` command through that queue. - -For a read-only decision, use `check` in place of `ensure`. Exit 0 means compatible, 2 means build required, and 1 means an operational error. An app installed outside this helper is initially unknown and gets rebuilt once. Records are local to the simulator host under `~/.cache/t3code/native-clients` and work across checkouts. Do not copy records between machines or write them manually. - -A JavaScript-only diff, bundle identifier, app version, or recent install date does not prove native compatibility. Always check the whole checkout. Expo fingerprints are computed locally with `APP_VARIANT=development`; no EAS credentials or cloud build are required. Generated `ios/` and `android/` directories are excluded by `.fingerprintignore`, so edit native source modules or config plugins rather than generated output. - -The development identity is `T3 Code Dev`, bundle/package `com.t3tools.t3code.dev`, scheme `t3code-dev`. If a build fails, investigate the build error and fix the local prerequisites. Report the concrete failure if it cannot be resolved, not “no compatible client.” - -## Start one disposable T3 environment - -Run backend commands from the repository root. Use the ignored, worktree-local `.t3` directory or create a fresh directory with the host OS's temporary-directory mechanism. An explicit base directory stores state in `/userdata`; never point testing at shared `~/.t3` state. - -Seed a small number of meaningful Git projects before starting the backend: - -```bash -node apps/server/src/bin.ts project add \ - --base-dir \ - --title -``` - -Running `project add` before the backend starts gives it exclusive offline database access. If a backend is already running, wait until it is ready so the CLI dispatches through the live server; never run offline mutations concurrently with the server. - -Use direct SQLite mutation only for disposable projection fixtures. Follow `test-t3-app` and stop the backend before writing. - -Start a headless backend after seeding: - -```bash -node apps/server/src/bin.ts serve \ - --host 127.0.0.1 \ - --port \ - --base-dir \ - --no-browser -``` - -Use these client origins: - -- iOS Simulator: `http://127.0.0.1:` -- Android Emulator: `http://10.0.2.2:` -- Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin - -Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. - -## Start or reuse Metro safely - -Run Metro from `apps/mobile`. - -1. Inspect any process on the intended Metro port and its `/status` response. Reuse it only when it is healthy, belongs to this worktree, and matches `APP_VARIANT=development`, `--dev-client`, and scheme `t3code-dev`. -2. Never kill another worktree's Metro. Use a free explicit port when necessary. -3. Run `vp run dev:client` on the standard port. For another port, retain the complete development identity: - - ```bash - APP_VARIANT=development vp exec expo start \ - --dev-client \ - --scheme t3code-dev \ - --lan \ - --port - ``` - - In PowerShell, set `$env:APP_VARIANT = "development"` first and then run the `vp exec expo start ...` command without the leading assignment. - -4. Open the exact development-client URL for the selected device and confirm the loaded bundle belongs to this worktree and Metro port. - -### iOS launch - -Use `ios-debugger-agent` to select one UDID and set these XcodeBuildMCP session defaults: - -- Workspace: `/apps/mobile/ios/T3CodeDev.xcworkspace` -- Scheme: `T3CodeDev` -- Configuration: `Debug` -- Simulator ID: the selected UDID -- Bundle ID: `com.t3tools.t3code.dev` - -After `ensure` succeeds, open the Metro URL: +From the checkout being tested on the selected device host, run: ```bash -xcrun simctl openurl +node scripts/mobile-native-client.ts ensure ``` -Accept the iOS confirmation prompt and dismiss the developer menu when it obscures the app. +This reuses a matching native client or builds and installs one. Authorized +mobile verification includes that build step unless the user prohibits it. -### Android launch - -Use the emulator serial already checked by `ensure`: - -```bash -adb -s reverse tcp: tcp: -adb -s shell am start -W \ - -a android.intent.action.VIEW \ - -d '' \ - com.t3tools.t3code.dev -``` +Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging +to this checkout. Open its printed development-client URL with AgentDevice +`open com.t3tools.t3code.dev ` and all returned target arguments. +The device must be able to reach both Metro and the isolated backend. -Do not start, stop, erase, or reconfigure an emulator owned by another task. Track and later stop only processes owned by this test. +## Pair and verify -## Pair each client once - -Use the bundled helper from the repository root. It issues a fresh credential against the running backend's exact base directory, opens the existing Add Environment route with the credential in an encoded query parameter, and asks that route to connect once: +Use the helper from the repository root, with the returned executable and target +arguments stored in `agent_device_command` and the Bash array +`agent_device_target_args`: ```bash .agents/skills/test-t3-mobile/scripts/pair-client.sh \ - ios - -.agents/skills/test-t3-mobile/scripts/pair-client.sh \ - android -``` - -Run only the command for the selected platform. The helper uses `http://127.0.0.1:` for iOS and `http://10.0.2.2:` for Android. Pass a fifth argument only when testing a non-development URL scheme. - -The helper opens this registered route: - -```text -t3code-dev://connections/new?pairingUrl=&autoConnect=1 + \ + "$agent_device_command" "${agent_device_target_args[@]}" ``` -The Add Environment route owns the behavior: `pairingUrl` prefills its normal host and token inputs, while `autoConnect=1` submits once in development builds and returns to Home after success. Without `autoConnect`, the same route only prefills the form for manual inspection. - -Do not enter pairing hosts or tokens through simulator keyboard automation. Xcode's semantic typer sends HID-style key events through the simulator's active keyboard state, which can corrupt uppercase tokens and punctuation even when the host Mac uses a U.S. input source. The one-shot route is the deterministic pairing path. Use the visible form only as a fallback, and paste credentials rather than typing them character by character. - -Verify the expected seeded projects appear before exercising the affected flow. - -Pairing credentials are secret, short-lived, and single-use. Create a different credential for every simulator, emulator, physical device, or browser. If an attempt fails, issue a new credential rather than retrying the old one. Do not expose tokens in screenshots, commits, or final responses. - -## Drive and observe the affected flow - -### iOS - -Use `snapshot_ui` and current element references from XcodeBuildMCP for taps and typing. Stream the same UDID through `ios-simulator-browser` so the user can watch in T3 Code when the host supports it. Use the stream as a visual feed rather than a reason to switch to fragile browser coordinates. - -### Android - -Prefer semantic Android automation exposed by the current agent host. Otherwise inspect the current hierarchy with `adb shell uiautomator dump`, target stable resource IDs, content descriptions, text, or bounds, and use scoped `adb shell input` actions. Refresh the hierarchy after navigation. Capture the final state with `adb exec-out screencap -p`. - -Android does not use serve-sim. Use a browser-compatible Android mirror when the host already provides one; otherwise return focused emulator screenshots as evidence rather than installing unrelated streaming infrastructure during verification. - -## Verify and clean up - -Exercise only the affected flow on one representative device unless the change specifically concerns platform, OS version, or screen size. Before finishing: - -1. Confirm the app connected to the intended disposable environment instead of merely rendering an empty disconnected state. -2. Capture the relevant final state. -3. Remove the disposable environment from T3 Code Dev. -4. Remove any `adb reverse` rule created for this test with `adb -s reverse --remove tcp:`. -5. Stop only the serve-sim, Metro, backend, emulator, and log processes started by this test. -6. Remove only base directories and temporary Git repositories deliberately created for this test. Preserve them when they contain useful reproduction evidence. - -Keep local verification focused. Do not turn this workflow into a full repository test run. - -## Troubleshoot predictable failures +It issues a fresh credential and opens T3 Code Dev's existing pairing route +through AgentDevice. For a backend on the device host, use +`http://127.0.0.1:` on iOS or `http://10.0.2.2:` +on Android. For a remote backend, use its reachable origin. -- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. -- **Metro serves stale or invalid transforms after those checks:** stop the owned Metro process and run `vp run dev:client:reset` once on the standard port. For a custom port, add `--clear` to the complete explicit `expo start` command above. -- **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. -- **A second client cannot pair:** pairing tokens are single-use; issue another token. -- **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`. -- **Pairing text changes case or punctuation:** do not retry semantic typing. Use `scripts/pair-client.sh`; the simulator keyboard layout and HID input path are not reliable for credentials. -- **iOS semantic actions fail:** set explicit XcodeBuildMCP defaults and refresh with `snapshot_ui`. -- **Android cannot reach Metro:** verify `adb reverse` for the exact Metro port and relaunch the development-client URL. -- **Android cannot reach the backend:** use `10.0.2.2`, not `127.0.0.1`, for the Android Emulator. +Confirm the intended projects appear, exercise the affected flow, and capture +evidence. Retain the app and environment while iterating. At teardown, remove +the disposable connection, close the AgentDevice session, call `device_close`, +and stop only your backend and Metro processes. diff --git a/.agents/skills/test-t3-mobile/agents/openai.yaml b/.agents/skills/test-t3-mobile/agents/openai.yaml index e9518ce9e04a..bda3335f47e5 100644 --- a/.agents/skills/test-t3-mobile/agents/openai.yaml +++ b/.agents/skills/test-t3-mobile/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Test T3 Mobile" - short_description: "Test T3 Code on iOS or Android" - default_prompt: "Use $test-t3-mobile to launch T3 Code against an isolated backend and verify the affected flow on an available simulator or emulator." + short_description: "Test iOS and Android in T3's Device panel" + default_prompt: "Use $test-t3-mobile to verify the affected native mobile flow through T3's Device panel and AgentDevice." diff --git a/.agents/skills/test-t3-mobile/scripts/pair-client.sh b/.agents/skills/test-t3-mobile/scripts/pair-client.sh index 9caa060728ec..6eeb14844a65 100755 --- a/.agents/skills/test-t3-mobile/scripts/pair-client.sh +++ b/.agents/skills/test-t3-mobile/scripts/pair-client.sh @@ -3,29 +3,17 @@ set -euo pipefail usage() { - echo "Usage: $0 [url-scheme]" >&2 + echo "Usage: $0 " >&2 exit 2 } -[[ $# -ge 4 && $# -le 5 ]] || usage +[[ $# -ge 5 ]] || usage -platform="$1" -device_id="$2" -server_port="$3" -base_dir="$4" -url_scheme="${5:-t3code-dev}" - -case "$platform" in - ios) - mobile_origin="http://127.0.0.1:${server_port}" - ;; - android) - mobile_origin="http://10.0.2.2:${server_port}" - ;; - *) - usage - ;; -esac +server_port="$1" +base_dir="$2" +mobile_origin="$3" +agent_device_command="$4" +shift 4 repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" @@ -35,7 +23,7 @@ if ! pairing_output="$({ --base-dir "$base_dir" \ --base-url "$mobile_origin" \ --ttl 15m \ - --label "agent-mobile-${device_id:0:8}" + --label "agent-mobile" } 2>&1)"; then echo "Could not mint a mobile pairing credential." >&2 exit 1 @@ -47,26 +35,19 @@ if [[ -z "$pairing_url" ]]; then exit 1 fi -deep_link="$(PAIRING_URL="$pairing_url" URL_SCHEME="$url_scheme" node - <<'NODE' +deep_link="$(PAIRING_URL="$pairing_url" node - <<'NODE' const query = new URLSearchParams({ pairingUrl: process.env.PAIRING_URL, autoConnect: "1", }); -process.stdout.write(`${process.env.URL_SCHEME}://connections/new?${query}`); +process.stdout.write(`t3code-dev://connections/new?${query}`); NODE )" -case "$platform" in - ios) - xcrun simctl openurl "$device_id" "$deep_link" - ;; - android) - # adb shell re-joins its arguments and evaluates them through the device - # shell, so the deep link's `?`/`&` must be quoted once more for that shell. - adb -s "$device_id" shell \ - "am start -W -a android.intent.action.VIEW -d '$deep_link' com.t3tools.t3code.dev" \ - >/dev/null - ;; -esac +if ! "$agent_device_command" open com.t3tools.t3code.dev "$deep_link" "$@" \ + >/dev/null 2>&1; then + echo "AgentDevice could not open the pairing route. Check the Device panel and retry with a fresh credential." >&2 + exit 1 +fi echo "Opened the existing Add Environment route with a fresh pairing credential." diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index a6369d240fc7..000000000000 --- a/.codex/config.toml +++ /dev/null @@ -1,9 +0,0 @@ -[mcp_servers.xcodebuildmcp] -enabled = true -required = false -command = "npx" -args = ["--yes", "xcodebuildmcp@2.6.2", "mcp"] -startup_timeout_sec = 20.0 - -[mcp_servers.xcodebuildmcp.env] -XCODEBUILDMCP_ENABLED_WORKFLOWS = "simulator,ui-automation,debugging,logging" diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 213a64995fab..000000000000 --- a/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "xcodebuildmcp": { - "command": "npx", - "args": ["--yes", "xcodebuildmcp@2.6.2", "mcp"], - "env": { - "XCODEBUILDMCP_ENABLED_WORKFLOWS": "simulator,ui-automation,debugging,logging" - } - } - } -} From bd5dc58c65b87bfd2222e4697c4742b48e639ea4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 18 Sep 2026 13:52:38 -0400 Subject: [PATCH 003/111] fix(web): client spans reach the trace proxy again (#12332) Signed-off-by: Yordis Prieto --- apps/web/src/lib/runtime.test.ts | 28 ++++++++++++++++++++ apps/web/src/lib/runtime.ts | 3 +++ apps/web/src/observability/clientTracer.ts | 29 +++++++++++++++++++++ apps/web/src/observability/clientTracing.ts | 9 +++---- 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/lib/runtime.test.ts create mode 100644 apps/web/src/observability/clientTracer.ts diff --git a/apps/web/src/lib/runtime.test.ts b/apps/web/src/lib/runtime.test.ts new file mode 100644 index 000000000000..339ccf711a10 --- /dev/null +++ b/apps/web/src/lib/runtime.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Tracer from "effect/Tracer"; + +import * as ClientTracer from "../observability/clientTracer"; +import { runtime } from "./runtime"; + +describe("web runtime", () => { + it("routes client spans to the exporter client tracing configured", async () => { + const exported: Array = []; + ClientTracer.setDelegate( + Tracer.make({ + span(options) { + exported.push(options.name); + return new Tracer.NativeSpan(options); + }, + }), + ); + + try { + await runtime.runPromise(Effect.void.pipe(Effect.withSpan("client.work"))); + } finally { + ClientTracer.setDelegate(null); + } + + expect(exported).toEqual(["client.work"]); + }); +}); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 754d3825bc58..866f38875c76 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -11,6 +11,7 @@ import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { browserCryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; +import * as ClientTracer from "../observability/clientTracer"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; @@ -29,6 +30,7 @@ type RuntimeLayerSource = | typeof browserCryptoLayer | typeof Socket.layerWebSocketConstructorGlobal | typeof relayTracingLayer + | typeof ClientTracer.layer | ReturnType; const primaryHttpRuntime = ManagedRuntime.make( @@ -56,6 +58,7 @@ const runtimeLayer = Layer.mergeAll( httpClientLayer, browserCryptoLayer, Socket.layerWebSocketConstructorGlobal, + ClientTracer.layer, relayTracingLayer, managedRelayClientLayer(configuredRelayUrl()).pipe( Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)), diff --git a/apps/web/src/observability/clientTracer.ts b/apps/web/src/observability/clientTracer.ts new file mode 100644 index 000000000000..773c1717f713 --- /dev/null +++ b/apps/web/src/observability/clientTracer.ts @@ -0,0 +1,29 @@ +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; + +let delegate: Tracer.Tracer | null = null; + +/** + * Points the installed tracer at the exporter `configureClientTracing` just built, + * or at nothing while no exporter is configured. + */ +export function setDelegate(next: Tracer.Tracer | null): void { + delegate = next; +} + +export function hasDelegate(): boolean { + return delegate !== null; +} + +/** + * Installed once when the client runtime is built, before any exporter exists, so + * client spans keep flowing to whatever exporter is configured later on. + */ +export const layer = Layer.succeed( + Tracer.Tracer, + Tracer.make({ + span(options) { + return delegate?.span(options) ?? new Tracer.NativeSpan(options); + }, + }), +); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 81cd18e207de..2471de958615 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -2,13 +2,13 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Scope from "effect/Scope"; -import * as Tracer from "effect/Tracer"; import { HttpClient } from "effect/unstable/http"; import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; +import * as ClientTracer from "./clientTracer"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; @@ -30,7 +30,6 @@ const delegateRuntimeLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); -let activeDelegate: Tracer.Tracer | null = null; let activeRuntime: ManagedRuntime.ManagedRuntime | null = null; let activeScope: Scope.Closeable | null = null; let activeConfigKey: string | null = null; @@ -54,7 +53,7 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise Date: Fri, 18 Sep 2026 10:54:46 -0700 Subject: [PATCH 004/111] fix(bitbucket): preserve rate limits from optional PR reads (#12486) --- .../BitbucketPullRequestProvider.test.ts | 81 ++++++++++++++++++- .../BitbucketPullRequestProvider.ts | 42 ++++++++-- .../src/sourceControl/BitbucketApi.test.ts | 24 ++++++ apps/server/src/sourceControl/BitbucketApi.ts | 5 +- 4 files changed, 142 insertions(+), 10 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts index db06a360a55c..f233880fd95d 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts @@ -1,11 +1,90 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; +import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts"; import { bitbucketProviderFailure, bitbucketViewerPermissions, + make, } from "./BitbucketPullRequestProvider.ts"; +for (const operation of [ + "getMergeability", + "listChecks", + "getRepositoryPermission", + "listComments", + "listCommits", +] as const) { + it.effect.each(["response", "body read"])( + `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`, + (variant) => + Effect.gen(function* () { + const pullRequest = Result.getOrThrow( + decodePullRequestJson(`{ + "id": 1, "title": "Check polling", "state": "OPEN", + "source": { "branch": { "name": "feature" } }, + "destination": { "branch": { "name": "main" } }, + "created_on": "2026-09-16T00:00:00Z", + "updated_on": "2026-09-16T00:00:00Z", + "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } + }`), + ); + for (const status of [429, 403]) { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ + getPullRequest: () => Effect.succeed(pullRequest), + getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }), + getMergeability: () => Effect.succeed("unknown" as const), + listChecks: () => Effect.succeed([]), + getRepositoryPermission: () => Effect.succeed(true), + listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }), + listCommits: () => Effect.succeed([]), + [operation]: () => + Effect.fail( + variant === "response" + ? new BitbucketApi.BitbucketResponseError({ + operation: "request", + status, + responseBodyLength: 0, + retryAt: 120_000, + }) + : new BitbucketApi.BitbucketResponseBodyReadError({ + operation: "request", + status, + cause: new Error("response stream failed"), + retryAt: 120_000, + }), + ), + }), + ), + ); + const reference = { + cwd: "/repo", + repository: "acme/web", + number: 1, + host: "bitbucket.org", + }; + const result = yield* operation === "listComments" || operation === "listCommits" + ? Effect.result(provider.getChangeRequestActivity(reference)) + : Effect.result(provider.getChangeRequest(reference)); + if (status === 429) { + expect(result).toMatchObject({ + _tag: "Failure", + failure: { reason: "rate-limited", retryAt: 120_000 }, + }); + } else { + expect(result._tag).toBe("Success"); + } + } + }), + ); +} + describe("bitbucketProviderFailure", () => { it("treats only an HTTP 401 as unusable credentials", () => { const responseError = (status: number) => diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index b9fe9d669f4a..634519d467b9 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -73,7 +73,10 @@ export function bitbucketProviderFailure( if (error._tag === "BitbucketResponseError" && error.status === 401) { return { reason: "unauthenticated" }; } - if (error._tag === "BitbucketResponseError" && error.status === 429) { + if ( + (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && + error.status === 429 + ) { return { reason: "rate-limited", ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), @@ -122,6 +125,31 @@ export const make = Effect.gen(function* () { cause: error, }); + const recoverRead = ( + read: Effect.Effect, + fallback: A, + ) => { + const recover = () => Effect.succeed(fallback); + return Effect.catchTags(read, { + BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()), + BitbucketUntrustedUrlError: recover, + BitbucketRepositoryLocatorError: recover, + BitbucketRequestError: recover, + BitbucketResponseBodyReadError: (error) => + error.status === 429 ? Effect.fail(error) : recover(), + BitbucketResponseDecodeError: recover, + BitbucketRepositoryVcsResolveError: recover, + BitbucketRepositoryRemotesListError: recover, + BitbucketRepositoryRemoteNotFoundError: recover, + BitbucketPullRequestBodyReadError: recover, + BitbucketCheckoutError: recover, + BitbucketPullRequestReadError: recover, + BitbucketViewerUnavailableError: recover, + BitbucketRepositoryUnsupportedError: recover, + BitbucketDiffCommitError: recover, + }); + }; + const provider: PullRequestProviderApi = { kind: "bitbucket", capabilities: CAPABILITIES, @@ -156,12 +184,12 @@ export const make = Effect.gen(function* () { [ api.getPullRequest(target), api.getDiffStat(target), - api.getMergeability(target).pipe(Effect.orElseSucceed(() => "unknown" as const)), - api.listChecks(target).pipe(Effect.orElseSucceed(() => [])), + recoverRead(api.getMergeability(target), "unknown" as const), + recoverRead(api.listChecks(target), []), // A permission that could not be read is an unknown one, which is granted: a hidden // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses // at least says why. - api.getRepositoryPermission(target).pipe(Effect.orElseSucceed(() => true)), + recoverRead(api.getRepositoryPermission(target), true), ], { concurrency: 5 }, ).pipe( @@ -200,10 +228,8 @@ export const make = Effect.gen(function* () { // Reviews ride on the pull request itself, so this inexpensive core read is repeated // here rather than making the core response wait for the conversation endpoints. api.getPullRequest(target), - api - .listComments(target) - .pipe(Effect.orElseSucceed(() => ({ comments: [], threads: [], truncated: true }))), - api.listCommits(target).pipe(Effect.orElseSucceed(() => [])), + recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }), + recoverRead(api.listCommits(target), []), ], { concurrency: 3 }, ).pipe( diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 856d043a7fce..b27d56bef399 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -618,6 +618,30 @@ it.effect("preserves Bitbucket response body read failures as their immediate ca }).pipe(Effect.provide(layer)); }); +it.effect("keeps the 429 retry time when the response body cannot be read", () => { + const { layer } = makeLayer({ + response: () => + new Response( + new ReadableStream({ + start: (controller) => controller.error(new Error("response stream failed")), + }), + { status: 429, headers: { "Retry-After": "120" } }, + ), + }); + + return Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* bitbucket + .request({ method: "GET", url: "/repositories/acme/web" }) + .pipe(Effect.flip); + + assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); + assert.strictEqual(error.status, 429); + assert.strictEqual(error.retryAt, 121_000); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 7bac33a30be1..202740680be8 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -117,6 +117,7 @@ export class BitbucketResponseBodyReadError extends Schema.TaggedError Date: Fri, 18 Sep 2026 11:01:23 -0700 Subject: [PATCH 005/111] fix(mobile): synchronize native permission registry access (#12482) --- .../fixtures/PermissionsServiceRegression.m | 55 +++++++++++++++ .../scripts/permissions-service.test.ts | 68 +++++++++++++++++++ patches/expo-modules-core@57.0.14.patch | 36 ++++++++++ pnpm-lock.yaml | 10 +-- pnpm-workspace.yaml | 3 + 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/scripts/fixtures/PermissionsServiceRegression.m create mode 100644 apps/mobile/scripts/permissions-service.test.ts create mode 100644 patches/expo-modules-core@57.0.14.patch diff --git a/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m b/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m new file mode 100644 index 000000000000..7124178383d4 --- /dev/null +++ b/apps/mobile/scripts/fixtures/PermissionsServiceRegression.m @@ -0,0 +1,55 @@ +@interface TestPermissionRequester : NSObject +@end + +@implementation TestPermissionRequester ++ (NSString *)permissionType { return NSStringFromClass(self); } +- (NSDictionary *)getPermissions { return @{ @"status": @(EXPermissionStatusGranted) }; } +- (void)requestPermissionsWithResolver:(EXPromiseResolveBlock)resolve rejecter:(EXPromiseRejectBlock)reject { + resolve([self getPermissions]); +} +@end + +typedef struct { + __unsafe_unretained EXPermissionsService *service; + __unsafe_unretained NSArray *requesters; + size_t offset; +} Worker; + +static void *exerciseRegistry(void *context) { + Worker *worker = context; + for (size_t index = 0; index < 250; index++) { + @autoreleasepool { + id requester = worker->requesters[(index + worker->offset) % worker->requesters.count]; + [worker->service registerRequesters:@[requester]]; + id resolved = [worker->service getPermissionRequesterForType:[[requester class] permissionType]]; + assert(resolved == requester); + NSDictionary *permissions = [worker->service getPermissionUsingRequesterClass:[requester class]]; + assert([permissions[@"status"] isEqualToString:@"granted"]); + } + } + return NULL; +} + +int main(void) { + @autoreleasepool { + EXPermissionsService *service = [EXPermissionsService new]; + NSMutableArray *requesters = [NSMutableArray new]; + for (int i = 0; i < 32; i++) { + NSString *name = [NSString stringWithFormat:@"TestPermission%d", i]; + Class cls = objc_allocateClassPair([TestPermissionRequester class], name.UTF8String, 0); + objc_registerClassPair(cls); + [requesters addObject:[cls new]]; + } + Worker workers[8]; + pthread_t threads[8]; + for (size_t index = 0; index < 8; index++) { + workers[index] = (Worker){ service, requesters, index }; + assert(pthread_create(&threads[index], NULL, exerciseRegistry, &workers[index]) == 0); + } + for (size_t index = 0; index < 8; index++) { + assert(pthread_join(threads[index], NULL) == 0); + } + puts("passed"); + } + return 0; +} diff --git a/apps/mobile/scripts/permissions-service.test.ts b/apps/mobile/scripts/permissions-service.test.ts new file mode 100644 index 000000000000..f1851723550a --- /dev/null +++ b/apps/mobile/scripts/permissions-service.test.ts @@ -0,0 +1,68 @@ +// @effect-diagnostics nodeBuiltinImport:off - Compiles the native dependency regression directly. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { expect, it } from "vite-plus/test"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- This test compiles against the host Foundation framework. +it.skipIf(NodeOS.platform() !== "darwin")( + "registers and reads native permissions concurrently without corrupting the registry", + () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-permissions-test-")); + try { + const require = NodeModule.createRequire(import.meta.url); + const core = NodePath.dirname( + NodeModule.createRequire(require.resolve("expo/package.json")).resolve( + "expo-modules-core/package.json", + ), + ); + const read = (relativePath: string) => + NodeFS.readFileSync(NodePath.join(core, relativePath), "utf8").replace( + /^#import .*$/gm, + "", + ); + const source = NodePath.join(directory, "regression.m"); + NodeFS.writeFileSync( + source, + [ + "#import ", + "#import ", + "#include ", + "#include ", + "typedef void (^EXPromiseResolveBlock)(id result);", + "typedef void (^EXPromiseRejectBlock)(NSString *, NSString *, NSError *);", + "#define RCTLogWarn(...)", + read("ios/Interfaces/Permissions/EXPermissionsInterface.h"), + read("ios/Legacy/Services/Permissions/EXPermissionsService.h"), + read("ios/Legacy/Services/Permissions/EXPermissionsService.m"), + NodeFS.readFileSync( + new URL("./fixtures/PermissionsServiceRegression.m", import.meta.url), + "utf8", + ), + ].join("\n"), + ); + const executable = NodePath.join(directory, "regression"); + NodeChildProcess.execFileSync( + "clang", + [ + "-fobjc-arc", + "-fblocks", + "-fsanitize=thread", + "-framework", + "Foundation", + source, + "-o", + executable, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + expect( + NodeChildProcess.execFileSync(executable, { encoding: "utf8", timeout: 15_000 }).trim(), + ).toBe("passed"); + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } + }, +); diff --git a/patches/expo-modules-core@57.0.14.patch b/patches/expo-modules-core@57.0.14.patch new file mode 100644 index 000000000000..4cc8820a1e54 --- /dev/null +++ b/patches/expo-modules-core@57.0.14.patch @@ -0,0 +1,36 @@ +diff --git a/ios/Legacy/Services/Permissions/EXPermissionsService.m b/ios/Legacy/Services/Permissions/EXPermissionsService.m +--- a/ios/Legacy/Services/Permissions/EXPermissionsService.m ++++ b/ios/Legacy/Services/Permissions/EXPermissionsService.m +@@ -32,8 +32,11 @@ + + - (void)registerRequesters:(NSArray> *)newRequesters { + for (id requester in newRequesters) { +- [_requesters setObject:requester forKey:[[requester class] permissionType]]; +- [_requestersByClass setObject:requester forKey:[requester class]]; ++ NSString *permissionType = [[requester class] permissionType]; ++ @synchronized (self) { ++ [_requesters setObject:requester forKey:permissionType]; ++ [_requestersByClass setObject:requester forKey:[requester class]]; ++ } + } + } + +@@ -162,12 +165,16 @@ + + - (id)getPermissionRequesterForType:(NSString *)type + { +- return _requesters[type]; ++ @synchronized (self) { ++ return _requesters[type]; ++ } + } + + - (id)getPermissionRequesterForClass:(Class)requesterClass + { +- return [_requestersByClass objectForKey:requesterClass]; ++ @synchronized (self) { ++ return [_requestersByClass objectForKey:requesterClass]; ++ } + } + + @end diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5378ca24d5fb..10143c8498e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,7 @@ overrides: '@expo/dom-webview': 57.0.1 '@expo/metro-config': 57.0.12 expo-constants: 57.0.16 + expo-modules-core: 57.0.14 '@pierre/diffs>@shikijs/transformers': ^4.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 @@ -101,6 +102,7 @@ patchedDependencies: dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 effect@4.0.0-rc.115: 0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a + expo-modules-core@57.0.14: 6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6 react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2 @@ -17412,7 +17414,7 @@ snapshots: - supports-color - typescript - expo-modules-core@57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + expo-modules-core@57.0.14(patch_hash=6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: '@expo/expo-modules-macros-plugin': 0.6.1 expo-modules-jsi: 57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) @@ -17423,7 +17425,7 @@ snapshots: react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) optional: true - expo-modules-core@57.0.14(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-modules-core@57.0.14(patch_hash=6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.6.1 expo-modules-jsi: 57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -17605,7 +17607,7 @@ snapshots: expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 57.0.2(expo@57.0.18)(react@19.2.3) expo-modules-autolinking: 57.0.12(typescript@7.0.2) - expo-modules-core: 57.0.14(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core: 57.0.14(patch_hash=6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e)(react-native-worklets@0.11.4(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -17647,7 +17649,7 @@ snapshots: expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) expo-keep-awake: 57.0.2(expo@57.0.18)(react@19.2.6) expo-modules-autolinking: 57.0.12(typescript@6.0.3) - expo-modules-core: 57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-modules-core: 57.0.14(patch_hash=6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) pretty-format: 29.7.0 react: 19.2.6 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d97ff895b237..ca80c41bc739 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -143,6 +143,8 @@ overrides: "@expo/dom-webview": 57.0.1 "@expo/metro-config": 57.0.12 expo-constants: 57.0.16 + # Keep fresh resolution on the version carrying the permissions registry fix. + expo-modules-core: 57.0.14 "@pierre/diffs>@shikijs/transformers": ^4.2.0 # Keep one copy of these native-binary packages. lightningcss ships 11 platform binaries per # version, and the workspaces' tailwindcss ^4 ranges drift into two 4.3.x copies of the family. @@ -187,6 +189,7 @@ patchedDependencies: dbus-next@0.10.2: patches/dbus-next@0.10.2.patch effect@4.0.0-rc.115: patches/effect@4.0.0-rc.115.patch expo-audio@57.0.4: patches/expo-audio@57.0.4.patch + expo-modules-core@57.0.14: patches/expo-modules-core@57.0.14.patch expo-sharing@57.0.17: patches/expo-sharing@57.0.17.patch expo-widgets@57.0.15: patches/expo-widgets@57.0.15.patch react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch From ed6fe339d71863f39a1d5405554a3bac892e55b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 11:02:24 -0700 Subject: [PATCH 006/111] fix(build): retain multiple license notices for one package (#12489) --- scripts/lib/third-party-licenses.test.ts | 53 ++++++++++++++++++++++++ scripts/lib/third-party-licenses.ts | 27 +++++++----- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/scripts/lib/third-party-licenses.test.ts b/scripts/lib/third-party-licenses.test.ts index 997b2bd58d9e..e1607ebc6eb3 100644 --- a/scripts/lib/third-party-licenses.test.ts +++ b/scripts/lib/third-party-licenses.test.ts @@ -325,6 +325,59 @@ describe("third-party license generation", () => { expect(manifest.entries[0]?.noticeText).toBe("Override text"); }); + it("keeps all licenses and attributions in a package with multiple generated notices", async () => { + const fixture = await createFixture(); + await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); + for (const licenseId of ["Apache-2.0", "BSD-3-Clause"]) { + await writeJson( + NodePath.join( + fixture.root, + `.generated/third-party-licenses/spdx/v3.28.0/${licenseId}.json`, + ), + { licenseId, licenseText: `${licenseId} terms` }, + ); + } + await writeJson(fixture.configFile, { + packageOverrides: [ + { + name: "demo-dependency", + license: "(Apache-2.0 AND BSD-3-Clause)", + generatedNotices: [ + { licenseId: "Apache-2.0", copyrights: ["Copyright primary author"] }, + { licenseId: "BSD-3-Clause", copyrights: ["Copyright vendored author"] }, + ], + }, + ], + }); + const manifest = await generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }); + expect(manifest.entries[0]?.license).toBe("(Apache-2.0 AND BSD-3-Clause)"); + expect(manifest.entries[0]?.noticeText).toBe( + "Copyright primary author\n\nApache-2.0 terms\n\n---\n\nCopyright vendored author\n\nBSD-3-Clause terms", + ); + }); + + it("rejects conflicting package notice sources", async () => { + const fixture = await createFixture(); + await writeJson(fixture.configFile, { + packageOverrides: [ + { + name: "demo-dependency", + generatedNotice: { licenseId: "MIT" }, + generatedNotices: [{ licenseId: "BSD-3-Clause" }], + }, + ], + }); + await expect( + generateThirdPartyLicenseManifest({ + configFile: fixture.configFile, + packageManifests: [{ bundle: "web", path: fixture.appManifest }], + }), + ).rejects.toThrow("can define only one"); + }); + it("applies repository overrides across monorepo packages", async () => { const fixture = await createFixture(); await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE")); diff --git a/scripts/lib/third-party-licenses.ts b/scripts/lib/third-party-licenses.ts index 487853fab904..c1a5199f41b4 100644 --- a/scripts/lib/third-party-licenses.ts +++ b/scripts/lib/third-party-licenses.ts @@ -67,7 +67,7 @@ interface CustomNoticeConfigEntry { } interface PackageNoticeOverrideConfigEntry { - readonly generatedNotice?: GeneratedNoticeConfigEntry; + readonly generatedNotices?: ReadonlyArray; readonly license?: string; readonly name?: string; readonly noticeFile?: string; @@ -257,8 +257,16 @@ function decodePackageOverrides(value: unknown): ReadonlyArray + 1 + ) { + throw new Error( + `${context} can define only one of "noticeFile", "generatedNotice", or "generatedNotices".`, + ); } const sourceUrl = readOptionalString(entry, "sourceUrl", context); return { @@ -267,7 +275,8 @@ function decodePackageOverrides(value: unknown): ReadonlyArray { return [ ...config.customNotices.flatMap((notice) => notice.generatedNotices ?? []), - ...config.packageOverrides.flatMap((override) => - override.generatedNotice ? [override.generatedNotice] : [], - ), + ...config.packageOverrides.flatMap((override) => override.generatedNotices ?? []), ]; } @@ -780,9 +787,9 @@ async function packageEntry( } const repositoryKey = repositoryNoticeKey(collected.packageJson, license); - const noticeText = override?.generatedNotice + const noticeText = override?.generatedNotices ? await generatedNoticeText( - [override.generatedNotice], + override.generatedNotices, configDirectory, allowMissingGeneratedNotices, ) @@ -793,7 +800,7 @@ async function packageEntry( : ((await packageNoticeText(collected.packageRoot, packageNotices)) ?? (repositoryKey ? repositoryNotices.get(repositoryKey) : undefined)); if (!noticeText) { - if (override?.generatedNotice && allowMissingGeneratedNotices) return null; + if (override?.generatedNotices && allowMissingGeneratedNotices) return null; throw new Error( `${name}@${version} does not include a license or notice file. Add a package override with "noticeFile" or "generatedNotice" in the third-party license config.`, ); From dcdf5e6a013607b42836a270165286181da48296 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 11:04:16 -0700 Subject: [PATCH 007/111] fix(build): parse executable imports without matching source strings (#12488) --- apps/server/scripts/cli.ts | 2 +- scripts/lib/cli-executable-imports.ts | 49 +++++++++++++++++++++++ scripts/lib/cli-external-packages.test.ts | 24 ++++++++++- scripts/lib/cli-external-packages.ts | 37 ----------------- 4 files changed, 73 insertions(+), 39 deletions(-) create mode 100644 scripts/lib/cli-executable-imports.ts diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 58c3ad7b3d01..319440a7ec24 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -10,7 +10,7 @@ import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { DEVELOPMENT_ICON_OVERRIDES } from "../../../scripts/lib/brand-assets.ts"; -import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-external-packages.ts"; +import { findEsmImportsOfExternalPackages } from "../../../scripts/lib/cli-executable-imports.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { ServerCliBuildAssetMissingError, diff --git a/scripts/lib/cli-executable-imports.ts b/scripts/lib/cli-executable-imports.ts new file mode 100644 index 000000000000..3391445beb2a --- /dev/null +++ b/scripts/lib/cli-executable-imports.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeModule from "node:module"; +import ts from "typescript-legacy"; + +/** + * Scan an emitted bundle chunk for ESM imports of packages that are not Node + * built-ins. + * + * Inside a Node single-executable, `import` statements and `import()` can only + * resolve built-in modules; any file-backed specifier throws at module + * evaluation (static) or at first use (dynamic). External packages therefore + * have to be reached through `createRequire`, which reads the real filesystem + * in every runtime. The bundler cannot enforce this, so the check reads what it + * produced. + */ +export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray { + const specifiers = new Set(); + const module = ts.createSourceFile( + "bundle.mjs", + source, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.JS, + ); + const visit = (node: ts.Node): void => { + const dynamic = + ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword; + const specifierNode = + ts.isImportDeclaration(node) || ts.isExportDeclaration(node) + ? node.moduleSpecifier + : dynamic + ? node.arguments[0] + : undefined; + if (specifierNode && ts.isStringLiteralLike(specifierNode)) { + const specifier = specifierNode.text; + // Multi-runtime SDKs can retain optional Bun imports. Those are runtime + // built-ins, not packages to stage beside the executable. Static imports + // still fail here because Node would evaluate them unconditionally. + const runtimeBuiltin = + NodeModule.isBuiltin(specifier) || (dynamic && specifier.startsWith("bun:")); + if (!runtimeBuiltin && !specifier.startsWith("./") && !specifier.startsWith("../")) { + specifiers.add(specifier); + } + } + ts.forEachChild(node, visit); + }; + visit(module); + return [...specifiers].sort(); +} diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index d9954d0eef4a..ebcc81020bfe 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -9,9 +9,10 @@ import * as Schema from "effect/Schema"; import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; +import { findEsmImportsOfExternalPackages } from "./cli-executable-imports.ts"; + import { CLI_RUNTIME_EXTERNAL_PREFIXES, - findEsmImportsOfExternalPackages, findInlinedExternalPackages, selectCliRuntimeExternalDependencies, shouldBundleCliDependency, @@ -310,6 +311,27 @@ describe("findEsmImportsOfExternalPackages", () => { ]); }); + it("ignores imports inside generated extension source and comments", () => { + const source = [ + 'const extension = `import { Type } from "typebox";\nimport type { ExtensionAPI } from "@earendil-works/pi-coding-agent";`;', + '// import "comment-only";', + "const example = 'import(\"string-only\")';", + 'const interpolated = `source ${import("real-package")}`;', + ].join("\n"); + assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), ["real-package"]); + }); + + it("allows optional dynamic Bun built-ins but rejects static imports", () => { + assert.deepStrictEqual( + findEsmImportsOfExternalPackages('const load = () => import("bun:sqlite");'), + [], + ); + assert.deepStrictEqual( + findEsmImportsOfExternalPackages('import { Database } from "bun:sqlite";'), + ["bun:sqlite"], + ); + }); + it("does not mistake createRequire calls for imports", () => { const source = 'const { FileFinder } = createRequire(import.meta.url)("@ff-labs/fff-node");'; assert.deepStrictEqual(findEsmImportsOfExternalPackages(source), []); diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 5acffdfd3003..5cafee960675 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -1,6 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeModule from "node:module"; - /** * The single source of truth for packages the server CLI bundle must NOT inline. * @@ -81,40 +78,6 @@ export function selectCliRuntimeExternalDependencies( ); } -/** - * Scan an emitted bundle chunk for ESM imports of packages that are not Node - * built-ins. - * - * Inside a Node single-executable, `import` statements and `import()` can only - * resolve built-in modules; any file-backed specifier throws at module - * evaluation (static) or at first use (dynamic). External packages therefore - * have to be reached through `createRequire`, which reads the real filesystem - * in every runtime. The bundler cannot enforce this, so the check reads what it - * produced. - */ -export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray { - const specifiers = new Set(); - // `import x from`, `import "side-effect"`, `export ... from`, and `import()` - // all resolve through the module loader. - const patterns = [ - /^import\s[^;]*?\sfrom\s+["']([^"']+)["']/gm, - /^import\s+["']([^"']+)["']/gm, - /^export\s[^;]*?\sfrom\s+["']([^"']+)["']/gm, - // Rolldown may leave a `/* @vite-ignore */` style comment before the specifier. - /\bimport\(\s*(?:\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["']\s*[,)]/g, - ]; - for (const pattern of patterns) { - for (const match of source.matchAll(pattern)) { - const specifier = match[1]; - if (specifier === undefined) continue; - if (NodeModule.isBuiltin(specifier)) continue; - if (specifier.startsWith("./") || specifier.startsWith("../")) continue; - specifiers.add(specifier); - } - } - return [...specifiers].sort(); -} - /** * Scan an emitted bundle chunk for runtime-external packages that were inlined. * From 4c61e3d7ecc4d9957c243677d35dd3313a37c067 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 11:12:40 -0700 Subject: [PATCH 008/111] fix(mobile): synchronize native notification delegates (#12483) --- apps/mobile/package.json | 2 +- .../NotificationCenterManagerRegression.swift | 189 ++++++++++++++++++ .../notification-center-manager.test.ts | 65 ++++++ patches/expo-notifications@57.0.15.patch | 119 +++++++++++ pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 1 + 6 files changed, 379 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift create mode 100644 apps/mobile/scripts/notification-center-manager.test.ts create mode 100644 patches/expo-notifications@57.0.15.patch diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 031a23b666d1..b32117c03155 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -96,7 +96,7 @@ "expo-keep-awake": "~57.0.2", "expo-linking": "~57.0.8", "expo-network": "~57.0.1", - "expo-notifications": "~57.0.15", + "expo-notifications": "57.0.15", "expo-paste-input": "^0.1.15", "expo-quick-actions": "^6.0.2", "expo-secure-store": "~57.0.2", diff --git a/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift new file mode 100644 index 000000000000..6d13972d5742 --- /dev/null +++ b/apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift @@ -0,0 +1,189 @@ +import Foundation + +// The registry runs on macOS without starting a simulator. Only the OS-facing +// types are replaced; the tests compile the dependency's actual Swift source. +public enum UIBackgroundFetchResult { + case noData +} + +public struct UNNotificationPresentationOptions: OptionSet { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } +} + +public final class UNNotification: NSObject {} + +public final class UNNotificationResponse: NSObject { + let identifier: String + init(_ identifier: String) { self.identifier = identifier } +} + +public protocol UNUserNotificationCenterDelegate: AnyObject {} + +public final class UNUserNotificationCenter: NSObject { + private static let instance = UNUserNotificationCenter() + public weak var delegate: UNUserNotificationCenterDelegate? + public static func current() -> UNUserNotificationCenter { instance } +} + +private final class TestDelegate: NotificationDelegate { + private let lock = NSLock() + private var recordedEvents: [String] = [] + var onEvent: ((String) -> Void)? + var onResponse: ((UNNotificationResponse) -> Bool)? + + var events: [String] { lock.withLock { recordedEvents } } + + private func record(_ event: String) { + lock.withLock { recordedEvents.append(event) } + onEvent?(event) + } + + func didRegister(_ deviceToken: String) { record("registered") } + func didFailRegistration(_ error: Error) { record("failed") } + func openSettings(_ notification: UNNotification?) { record("settings") } + + func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { + record("present") + return false + } + + func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { + record("background") + return false + } + + func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { + record(response.identifier) + return onResponse?(response) ?? false + } +} + +private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { + if !condition() { + throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} + +private func deliver(_ identifier: String) { + NotificationCenterManager.shared.userNotificationCenter( + UNUserNotificationCenter.current(), + didReceive: UNNotificationResponse(identifier), + withCompletionHandler: {} + ) +} + +private func reentrantCallbacks() throws { + let manager = NotificationCenterManager.shared + let center = UNUserNotificationCenter.current() + let callbacks: [(String, () -> Void)] = [ + ("registered", { manager.didRegister("token") }), + ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }), + ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }), + ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }), + ("background", { manager.didReceive([:], completionHandler: { _ in }) }) + ] + + for (event, callback) in callbacks { + let original = TestDelegate() + let replacement = TestDelegate() + original.onEvent = { [weak original] _ in + guard let original else { return } + manager.removeDelegate(original) + manager.addDelegate(replacement) + } + manager.addDelegate(original) + callback() + try require(original.events == [event], "The original delegate must receive the callback once") + try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot") + callback() + try require(replacement.events == [event], "Reentrant registration must take effect for the next callback") + manager.removeDelegate(replacement) + } +} + +private func registrationDuringDelivery() throws { + let manager = NotificationCenterManager.shared + let original = TestDelegate() + let receiver = TestDelegate() + receiver.onResponse = { _ in true } + original.onResponse = { _ in + manager.addDelegate(receiver) + return false + } + manager.addDelegate(original) + deliver("handoff") + try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response") + manager.removeDelegate(original) + manager.removeDelegate(receiver) +} + +private func responseDuringReplay() throws { + let manager = NotificationCenterManager.shared + deliver("first") + let original = TestDelegate() + original.onResponse = { response in + if response.identifier == "first" { + deliver("second") + return true + } + return false + } + manager.addDelegate(original) + let receiver = TestDelegate() + receiver.onResponse = { _ in true } + manager.addDelegate(receiver) + try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks") + manager.removeDelegate(original) + manager.removeDelegate(receiver) +} + +private func concurrentRegistrations() throws { + let manager = NotificationCenterManager.shared + let delegates = (0..<512).map { _ in + let delegate = TestDelegate() + delegate.onResponse = { _ in true } + return delegate + } + DispatchQueue.concurrentPerform(iterations: delegates.count) { index in + manager.addDelegate(delegates[index]) + if index.isMultiple(of: 16) { + manager.didRegister("during-add") + deliver("during-add") + } + } + let before = delegates.map { $0.events.count } + manager.didRegister("after-add") + for (index, delegate) in delegates.enumerated() { + try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once") + } + DispatchQueue.concurrentPerform(iterations: delegates.count) { index in + manager.removeDelegate(delegates[index]) + if index.isMultiple(of: 16) { + manager.didRegister("during-remove") + deliver("during-remove") + } + } + let removed = delegates.map { $0.events.count } + manager.didRegister("after-remove") + try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") +} + +@main +private enum RegressionTests { + static func main() { + do { + switch CommandLine.arguments.last { + case "reentrant": try reentrantCallbacks() + case "handoff": try registrationDuringDelivery() + case "pending": try responseDuringReplay() + case "concurrent": try concurrentRegistrations() + default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) + } + print("passed") + } catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(1) + } + } +} diff --git a/apps/mobile/scripts/notification-center-manager.test.ts b/apps/mobile/scripts/notification-center-manager.test.ts new file mode 100644 index 000000000000..e7a059496af6 --- /dev/null +++ b/apps/mobile/scripts/notification-center-manager.test.ts @@ -0,0 +1,65 @@ +// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler. +describe.skipIf(NodeOS.platform() !== "darwin")( + "NotificationCenterManager native concurrency", + () => { + let directory: string; + let executable: string; + + beforeAll(() => { + directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-")); + executable = NodePath.join(directory, "notification-regression"); + const source = NodeFS.readFileSync( + new URL( + "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift", + import.meta.url, + ), + "utf8", + ); + const manager = NodePath.join(directory, "NotificationCenterManager.swift"); + NodeFS.writeFileSync( + manager, + source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""), + ); + NodeChildProcess.execFileSync( + "swiftc", + [ + "-swift-version", + "5", + "-sanitize=thread", + manager, + NodeURL.fileURLToPath( + new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url), + ), + "-o", + executable, + ], + { timeout: 30_000, encoding: "utf8" }, + ); + }); + + afterAll(() => { + if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); + }); + + it.each([ + ["reentrant", "allows callbacks to replace delegates without deadlocking"], + ["handoff", "delivers responses to delegates registering during delivery"], + ["pending", "retains new responses received while replaying pending responses"], + ["concurrent", "registers, removes, and broadcasts concurrently without data races"], + ])("%s: %s", (name) => { + const output = NodeChildProcess.execFileSync(executable, [name], { + encoding: "utf8", + timeout: 15_000, + }); + expect(output.trim()).toBe("passed"); + }); + }, +); diff --git a/patches/expo-notifications@57.0.15.patch b/patches/expo-notifications@57.0.15.patch new file mode 100644 index 000000000000..93ad9e704dd5 --- /dev/null +++ b/patches/expo-notifications@57.0.15.patch @@ -0,0 +1,119 @@ +diff --git a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift +--- a/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift ++++ b/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift +@@ -42,8 +42,10 @@ + @objc + public static let shared = NotificationCenterManager() + +- var delegates: [NotificationDelegate] = [] +- var pendingResponses: [UNNotificationResponse] = [] ++ // Module creation and destruction can overlap across React runtimes. ++ private let stateLock = NSLock() ++ private var delegates: [NotificationDelegate] = [] ++ private var pendingResponses: [UNNotificationResponse] = [] + let userNotificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current() + + private override init() { +@@ -62,32 +64,49 @@ + } + + public func addDelegate(_ delegate: NotificationDelegate) { +- delegates.append(delegate) ++ let responses = stateLock.withLock { ++ delegates.append(delegate) ++ return pendingResponses ++ } + var handled = false +- for pendingResponse in pendingResponses { ++ for pendingResponse in responses { + handled = delegate.didReceive(pendingResponse, completionHandler: {}) || handled + } + if handled { +- pendingResponses.removeAll() ++ removePendingResponses(responses) + } + } + + public func removeDelegate(_ delegate: AnyObject) { +- if let index = delegates.firstIndex(where: { $0 === delegate }) { +- delegates.remove(at: index) ++ stateLock.withLock { ++ if let index = delegates.firstIndex(where: { $0 === delegate }) { ++ delegates.remove(at: index) ++ } ++ } ++ } ++ ++ private func snapshotDelegates() -> [NotificationDelegate] { ++ stateLock.withLock { delegates } ++ } ++ ++ private func removePendingResponses(_ responses: [UNNotificationResponse]) { ++ let identifiers = Set(responses.map(ObjectIdentifier.init)) ++ stateLock.withLock { ++ // Keep responses received while delegate callbacks were running. ++ pendingResponses.removeAll { identifiers.contains(ObjectIdentifier($0)) } + } + } + + // MARK: - Called by PushTokenAppDelegateSubscriber + + public func didFailRegistration(_ error: any Error) { +- for delegate in delegates { ++ for delegate in snapshotDelegates() { + delegate.didFailRegistration(error) + } + } + + public func didRegister(_ deviceToken: String) { +- for delegate in delegates { ++ for delegate in snapshotDelegates() { + delegate.didRegister(deviceToken) + } + } +@@ -100,7 +119,7 @@ + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + var handled = false +- for delegate in delegates { ++ for delegate in snapshotDelegates() { + handled = delegate.willPresent(notification, completionHandler: completionHandler) || handled + } + if !handled { +@@ -113,18 +132,23 @@ + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { ++ let currentDelegates = stateLock.withLock { ++ // A delegate registering during delivery must see this response too. ++ pendingResponses.append(response) ++ return delegates ++ } + var handled = false +- for delegate in delegates { ++ for delegate in currentDelegates { + handled = delegate.didReceive(response, completionHandler: completionHandler) || handled + } +- if !handled { +- pendingResponses.append(response) ++ if handled { ++ removePendingResponses([response]) + } + completionHandler() + } + + public func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { +- for delegate in delegates { ++ for delegate in snapshotDelegates() { + delegate.openSettings(notification) + } + } +@@ -132,7 +156,7 @@ + // MARK: - Called from NotificationsAppDelegateSubscriber + public func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + var handled = false +- for delegate in delegates { ++ for delegate in snapshotDelegates() { + handled = delegate.didReceive(userInfo, completionHandler: completionHandler) || handled + } + if !handled { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10143c8498e8..a0daed3ff962 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,7 @@ patchedDependencies: effect@4.0.0-rc.115: 0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a expo-modules-core@57.0.14: 6be7092f8c6f03a57b75c797d98b5f19ed62d2dd205bfc4c8adf4cef45655d7e + expo-notifications@57.0.15: f89252c2c08dc7b3acb8415f319ebbe15fff8c9f88a64ebe1830cd5ac5c9ac40 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6 react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2 @@ -389,8 +390,8 @@ importers: specifier: ~57.0.1 version: 57.0.1(expo@57.0.18)(react@19.2.3) expo-notifications: - specifier: ~57.0.15 - version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) + specifier: 57.0.15 + version: 57.0.15(patch_hash=f89252c2c08dc7b3acb8415f319ebbe15fff8c9f88a64ebe1830cd5ac5c9ac40)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) expo-paste-input: specifier: ^0.1.15 version: 0.1.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -17449,7 +17450,7 @@ snapshots: expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) react: 19.2.3 - expo-notifications@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2): + expo-notifications@57.0.15(patch_hash=f89252c2c08dc7b3acb8415f319ebbe15fff8c9f88a64ebe1830cd5ac5c9ac40)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2): dependencies: '@expo/image-utils': 0.11.5(typescript@7.0.2) abort-controller: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ca80c41bc739..0b4e86c2d5dd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -190,6 +190,7 @@ patchedDependencies: effect@4.0.0-rc.115: patches/effect@4.0.0-rc.115.patch expo-audio@57.0.4: patches/expo-audio@57.0.4.patch expo-modules-core@57.0.14: patches/expo-modules-core@57.0.14.patch + expo-notifications@57.0.15: patches/expo-notifications@57.0.15.patch expo-sharing@57.0.17: patches/expo-sharing@57.0.17.patch expo-widgets@57.0.15: patches/expo-widgets@57.0.15.patch react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch From 80c1f771b39674ada4d3724610f08fba855fdd49 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 11:13:18 -0700 Subject: [PATCH 009/111] fix(relay): accept delegated thread IDs in activity routes (#12484) --- .../migration.sql | 2 + .../snapshot.json | 1516 +++++++++++++++++ infra/relay/src/http/Api.test.ts | 109 +- infra/relay/src/http/Api.ts | 6 + infra/relay/src/persistence/schema.ts | 4 +- infra/relay/src/worker.ts | 2 + 6 files changed, 1628 insertions(+), 11 deletions(-) create mode 100644 infra/relay/migrations/postgres/20260918175607_long_thread_ids/migration.sql create mode 100644 infra/relay/migrations/postgres/20260918175607_long_thread_ids/snapshot.json diff --git a/infra/relay/migrations/postgres/20260918175607_long_thread_ids/migration.sql b/infra/relay/migrations/postgres/20260918175607_long_thread_ids/migration.sql new file mode 100644 index 000000000000..1bb8be055900 --- /dev/null +++ b/infra/relay/migrations/postgres/20260918175607_long_thread_ids/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "relay_agent_activity_rows" ALTER COLUMN "thread_id" SET DATA TYPE varchar(512) USING "thread_id"::varchar(512);--> statement-breakpoint +ALTER TABLE "relay_delivery_attempts" ALTER COLUMN "thread_id" SET DATA TYPE varchar(512) USING "thread_id"::varchar(512); \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20260918175607_long_thread_ids/snapshot.json b/infra/relay/migrations/postgres/20260918175607_long_thread_ids/snapshot.json new file mode 100644 index 000000000000..4e56b797b94d --- /dev/null +++ b/infra/relay/migrations/postgres/20260918175607_long_thread_ids/snapshot.json @@ -0,0 +1,1516 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "4b6d0d21-8d78-4499-9dde-b42bcf633d05", + "prevIds": ["1109ed23-036c-4c39-a459-52422a7ffd99"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_tunnel_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_tunnels", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "android_api_level", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aps_environment", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + }, + { + "columns": ["user_id"], + "nameExplicit": false, + "name": "relay_managed_tunnel_limits_pkey", + "schema": "public", + "table": "relay_managed_tunnel_limits", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 66b87e643a48..edaf1d08ed7a 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -1,4 +1,15 @@ +import * as HttpServer from "effect/unstable/http/HttpServer"; +import { + RelayClientAuth, + RelayClientPrincipal, + type RelayClientDeviceRecord, +} from "@t3tools/contracts/relay"; +import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; +import * as RelayTokens from "../auth/RelayTokens.ts"; +import * as Devices from "../agentActivity/Devices.ts"; import { createClerkClient, verifyToken } from "@clerk/backend"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Context from "effect/Context"; @@ -12,22 +23,21 @@ import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; +import * as Etag from "effect/unstable/http/Etag"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import * as HttpServer from "effect/unstable/http/HttpServer"; -import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { EnvironmentId } from "@t3tools/contracts"; import { - RelayApi, - RelayClientAuth, - RelayClientPrincipal, RelayEnvironmentAuth, - type RelayClientDeviceRecord, + RelayEnvironmentPrincipal, + RelayApi, } from "@t3tools/contracts/relay"; import { + RELAY_HTTP_ROUTER_CONFIG, RELAY_REQUEST_DEADLINE_MS, clientApi, relayCors, @@ -36,6 +46,7 @@ import { relayNotFoundRoute, relayDpopFailureReason, revokeEnvironmentLinkRecord, + serverApi, traceRelayHttpRequestWith, unlinkEnvironmentRecord, verifyRelayClientBearerToken, @@ -46,9 +57,8 @@ import * as RelayDb from "../db.ts"; import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; -import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; -import * as RelayTokens from "../auth/RelayTokens.ts"; -import * as Devices from "../agentActivity/Devices.ts"; +import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; +import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; vi.mock("@clerk/backend", () => ({ createClerkClient: vi.fn(), @@ -606,6 +616,87 @@ describe("relay request tracing", () => { }); describe("relay routing fallback", () => { + it.effect("publishes activity for escaped delegated thread IDs longer than 100 characters", () => + Effect.gen(function* () { + const environmentId = "environment-1"; + const environmentPublicKey = "environment-public-key"; + const published: Array< + Parameters[0] + > = []; + const verified: Array< + Parameters< + EnvironmentPublishSignatures.EnvironmentPublishSignatures["Service"]["verify"] + >[0] + > = []; + const publisher = Layer.succeed(AgentActivityPublisher.AgentActivityPublisher, { + publish: (input) => + Effect.sync(() => { + published.push(input); + return { ok: true as const, deliveries: [] }; + }), + replayForLiveActivityRegistration: () => Effect.succeed(null), + }); + const signatures = Layer.succeed(EnvironmentPublishSignatures.EnvironmentPublishSignatures, { + verify: (input) => + Effect.sync(() => { + verified.push(input); + }), + }); + const auth = Layer.succeed(RelayEnvironmentAuth, { + environmentBearer: (effect) => + effect.pipe( + Effect.provideService(RelayEnvironmentPrincipal, { + environmentId, + environmentPublicKey, + }), + ), + }); + const routes = HttpApiBuilder.layer( + HttpApi.make("RelayApi").add(RelayApi.groups.server), + ).pipe( + Layer.provide(serverApi.pipe(Layer.provide([publisher, signatures]))), + Layer.provide(auth), + Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layerWeak]), + ); + const httpEffect = yield* HttpRouter.toHttpEffect( + Layer.mergeAll(routes, relayNotFoundRoute, relayCors), + ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG)); + const threadIds = [ + "b7c8c522-d244-43dc-875f-7224fce79912", + "t".repeat(512), + "thread:delegated-task:command%3Amcp%3Ab7c8c522-d244-43dc-875f-7224fce79912%3Adelegate-task%3Agreet-subagent-20260813", + ]; + for (const threadId of threadIds) { + const request = HttpServerRequest.fromWeb( + new Request( + `https://relay.test/v1/environments/${environmentId}/threads/${encodeURIComponent(threadId)}/agent-activity`, + { + method: "POST", + headers: { + authorization: "Bearer environment-credential", + "content-type": "application/json", + }, + body: '{"state":null,"proof":"signed-proof"}', + }, + ), + ); + const response = yield* httpEffect.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + ); + expect(response.status).toBe(200); + } + expect(published).toEqual( + threadIds.map((threadId) => ({ + environmentId, + environmentPublicKey, + threadId, + state: null, + })), + ); + expect(verified.map((input) => input.threadId)).toEqual(threadIds); + }).pipe(Effect.scoped), + ); + it.effect("redirects the relay root to the API docs", () => Effect.gen(function* () { const request = HttpServerRequest.fromWeb(new Request("https://relay.test/")); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index fcaeca640420..5a4d0c9ca40c 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -71,6 +71,12 @@ import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; import { withSpanAttributes } from "../observability.ts"; import * as RelayDb from "../db.ts"; +// Delegated thread IDs carry escaped command provenance and exceed the router's +// default 100-character path parameter limit. Match the environment server. +export const RELAY_HTTP_ROUTER_CONFIG = { + maxParamLength: 512, +} as const; + const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; const relayCorsAllowedHeaders = [ "authorization", diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index d9a7074bc01d..fd766c758989 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -138,7 +138,7 @@ export const relayAgentActivityRows = pgTable( { environmentId: varchar("environment_id", { length: 191 }).notNull(), environmentPublicKey: text("environment_public_key").notNull(), - threadId: varchar("thread_id", { length: 191 }).notNull(), + threadId: varchar("thread_id", { length: 512 }).notNull(), stateJson: jsonb("state_json").notNull().$type(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), createdAt: varchar("created_at", { length: 64 }).notNull(), @@ -156,7 +156,7 @@ export const relayDeliveryAttempts = pgTable( createdAt: varchar("created_at", { length: 64 }).notNull(), userId: varchar("user_id", { length: 255 }), environmentId: varchar("environment_id", { length: 191 }), - threadId: varchar("thread_id", { length: 191 }), + threadId: varchar("thread_id", { length: 512 }), deviceId: varchar("device_id", { length: 255 }), kind: varchar("kind", { length: 64 }).notNull(), sourceJobId: varchar("source_job_id", { length: 64 }), diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 61020c40f45d..c9f343822fb0 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -23,6 +23,7 @@ import { healthApi, metadataApi, mobileApi, + RELAY_HTTP_ROUTER_CONFIG, relayClientAuthLayer, relayDpopClientAuthLayer, relayCors, @@ -345,6 +346,7 @@ export const ApiLive = Api.make( relayNotFoundRoute, ).pipe( HttpRouter.toHttpEffect, + Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG), withoutCapturedParentSpan, Effect.flatMap((httpEffect) => traceRelayHttpRequestWith(httpEffect, relayTraceLayer)), ); From c03b240265a1d4954536c5c0758ac2b7fa762ba8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 11:14:25 -0700 Subject: [PATCH 010/111] fix(git): explain fetch failures without exposing remote output (#12485) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 164 ++++++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 79 ++++++++- 2 files changed, 231 insertions(+), 12 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c09f36ed3150..f798abba74b6 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Metric from "effect/Metric"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; @@ -35,6 +36,8 @@ import { } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", }); @@ -718,7 +721,7 @@ it.effect("refreshes the current branch after an external checkout", () => ).pipe(Effect.provide(TestLayer)), ); -it.effect("backs off failed upstream refreshes across linked worktrees", () => +it.effect("backs off and logs failed fetch attempts across linked worktrees", () => Effect.scoped( Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -738,6 +741,14 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => const driver = yield* makeGitVcsDriverCore().pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), ); + const warnings: string[] = []; + const logger = Logger.make(({ message }) => { + warnings.push(String(message)); + }); + const readRemoteStatus = (workingDirectory: string) => + driver + .statusDetailsRemote(workingDirectory) + .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))); const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-vcs-driver-remote-"); const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); @@ -781,29 +792,146 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => ); yield* Ref.set(fetchAttempts, 0); - yield* driver.statusDetailsRemote(cwd); - yield* driver.statusDetailsRemote(worktreePath); + yield* readRemoteStatus(cwd); + yield* readRemoteStatus(worktreePath); assert.equal(yield* Ref.get(fetchAttempts), 1); + assert.lengthOf(warnings, 1); + assert.include(warnings[0], "Background Git fetch failed"); yield* TestClock.adjust("29 seconds"); - yield* driver.statusDetailsRemote(worktreePath); + yield* readRemoteStatus(worktreePath); assert.equal(yield* Ref.get(fetchAttempts), 1); + assert.lengthOf(warnings, 1); yield* TestClock.adjust("1 second"); - yield* driver.statusDetailsRemote(cwd); + yield* readRemoteStatus(cwd); assert.equal(yield* Ref.get(fetchAttempts), 2); + assert.lengthOf(warnings, 2); yield* TestClock.adjust("59 seconds"); - yield* driver.statusDetailsRemote(worktreePath); + yield* readRemoteStatus(worktreePath); assert.equal(yield* Ref.get(fetchAttempts), 2); + assert.lengthOf(warnings, 2); yield* TestClock.adjust("1 second"); - yield* driver.statusDetailsRemote(cwd); + yield* readRemoteStatus(cwd); assert.equal(yield* Ref.get(fetchAttempts), 3); + assert.lengthOf(warnings, 3); }), ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); +for (const scenario of [ + { + name: "HTTPS credentials", + stderr: "fatal: Authentication failed for", + expected: "could not authenticate", + }, + { + name: "SSH credentials", + stderr: "git@example.com: Permission denied (publickey).", + expected: "could not authenticate", + }, + { + name: "disabled prompts", + stderr: "fatal: could not read Username: terminal prompts disabled", + expected: "could not authenticate", + }, + { + name: "DNS failure", + stderr: "fatal: Could not resolve host: example.com", + expected: "could not reach the remote", + }, + { + name: "connection failure", + stderr: "ssh: connect to host example.com port 22: Connection refused", + expected: "could not reach the remote", + }, + { + name: "missing remote", + stderr: "remote: Repository not found.", + expected: "could not access the remote repository", + }, + { + name: "invalid remote", + stderr: "fatal: remote does not appear to be a git repository", + expected: "could not access the remote repository", + }, + { + name: "reference lock", + stderr: "error: cannot lock ref 'refs/remotes/origin/main': is at abc but expected def", + expected: "could not update a local reference", + }, + { + name: "lock file", + stderr: "fatal: Unable to create '/repo/.git/FETCH_HEAD.lock': File exists.", + expected: "could not update a local reference", + }, + { + name: "unrelated remote chatter", + stderr: + "remote: Help: authentication failed, connection refused, cannot lock ref\nremote: unrelated service error", + expected: "git fetch origin failed", + }, + { + name: "HTTPS DNS failure", + stderr: + "fatal: unable to access 'https://example.com/repo.git/': Could not resolve host: example.com", + expected: "could not reach the remote", + }, + { + name: "unknown failure", + stderr: "fatal: unexpected remote failure", + expected: "git fetch origin failed", + }, +] as const) { + it.effect(`reports ${scenario.name} during fetch without retaining remote output`, () => + Effect.gen(function* () { + const secret = "secret-fetch-token"; + const stderr = `${scenario.stderr}\nhttps://user:${secret}@example.com/private?token=${secret}`; + const attempts = yield* Ref.make(0); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("expected Git command"); + if (command.args[0] !== "fetch") return makeNonRepositoryHandle(); + assert.deepEqual(command.args, ["fetch", "--quiet", "origin"]); + assert.equal(command.options.env?.LC_ALL, "C"); + assert.equal(command.options.env?.GIT_TERMINAL_PROMPT, "0"); + yield* Ref.update(attempts, (count) => count + 1); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(secret)), + stderr: Stream.encodeText(Stream.make(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); + assert.include(error.detail, scenario.expected); + assert.equal(error.exitCode, 128); + assert.equal(error.stderrLength, stderr.length); + assert.equal(error.stdoutLength, secret.length); + assert.notInclude(error.message, secret); + assert.notInclude(yield* encodeGitCommandError(error), secret); + assert.notProperty(error, "stderr"); + assert.notProperty(error, "args"); + assert.equal(yield* Ref.get(attempts), 1); + }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), + ); +} + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { describe("process environment", () => { it.effect("preserves the caller locale for general Git subprocesses", () => @@ -2398,6 +2526,22 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + it.effect("explains a real fetch failure for a missing local remote", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const missingRemote = `${cwd}/private-missing-remote`; + yield* git(cwd, ["remote", "add", "origin", missingRemote]); + const error = yield* driver.fetchRemote({ cwd, remoteName: "origin" }).pipe(Effect.flip); + assert.include(error.detail, "could not access the remote repository"); + assert.equal(error.exitCode, 128); + assert.isAbove(error.stderrLength ?? 0, 0); + assert.notInclude(error.detail, missingRemote); + assert.notProperty(error, "stderr"); + }), + ); + it.effect("ensureRemote reuses an existing remote across ssh/https transport variants", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2544,7 +2688,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { if (Result.isFailure(result)) { assert.equal( result.failure.detail, - failure === "timeout" ? "Git command timed out." : "git fetch origin failed", + failure === "timeout" + ? "Git command timed out." + : failure === "offline" + ? "Git could not reach the remote. Check the server's network connection and remote host, then retry." + : "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.", ); } }), diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index c1136bb92601..56cff0cb9a58 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -469,6 +469,49 @@ function isMissingWorktreeStderr(stderr: string): boolean { ); } +// Fetch stderr can contain remote credentials. Only fixed diagnoses may enter +// persisted errors; unrecognized output keeps the generic failure message. +function fetchFailureDetail(stderr: string): string | undefined { + const lines = stderr.split(/\r?\n/).map((line) => line.trim()); + if ( + lines.some((line) => + /^(?:fatal: (?:Authentication failed|could not read (?:Username|Password))\b|\S+: Permission denied \(publickey)/i.test( + line, + ), + ) + ) { + return "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry."; + } + if ( + lines.some((line) => + /^(?:(?:fatal: |ssh: )?Could not resolve host(?:name)?\b|fatal: unable to access .+: (?:Could not resolve host|Failed to connect)\b|ssh: connect to host \S+ port \d+: (?:Connection timed out|Connection refused|Network is unreachable)\b)/i.test( + line, + ), + ) + ) { + return "Git could not reach the remote. Check the server's network connection and remote host, then retry."; + } + if ( + lines.some((line) => + /^(?:remote: Repository not found\.?$|fatal: repository .+ not found$|fatal: .+ does not appear to be a git repository$)/i.test( + line, + ), + ) + ) { + return "Git could not access the remote repository. Check the remote URL and repository permissions on the server."; + } + if ( + lines.some((line) => + /^(?:(?:error|fatal): cannot lock ref\b|fatal: Unable to create ['"].+\.lock['"]:)/i.test( + line, + ), + ) + ) { + return "Git could not update a local reference. Another Git operation or a stale lock may be blocking the fetch; check the repository on the server, then retry."; + } + return undefined; +} + interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; readonly flush: Effect.Effect; @@ -1307,6 +1350,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), + Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)), Effect.as(true as const), ); }); @@ -1331,13 +1375,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const upstream = yield* resolveCurrentUpstream(cwd); if (!upstream) return; const gitCommonDir = yield* resolveGitCommonDir(cwd); + // The cache loader logs failed attempts; cache hits keep using the last fetched refs. yield* Cache.get( statusRemoteRefreshCache, new StatusRemoteRefreshCacheKey({ gitCommonDir, remoteName: upstream.remoteName, }), - ); + ).pipe(Effect.ignore); }); const resolveDefaultBranchName = ( @@ -3224,7 +3269,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* env: STATUS_UPSTREAM_REFRESH_ENV, fallbackErrorDetail: `git fetch ${input.remoteName} failed`, }; - const fetchAll = executeGit("GitVcsDriver.fetchRemote", input.cwd, args, options); + const fetchAll = executeGitWithStableDiagnostics( + "GitVcsDriver.fetchRemote", + input.cwd, + args, + { + ...options, + allowNonZeroExit: true, + }, + ).pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.void + : Effect.fail( + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args, + }), + detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ), + ); if (input.refName === undefined) { return yield* fetchAll.pipe(Effect.asVoid); } @@ -3255,8 +3326,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: input.cwd, args: scopedArgs, }), - detail: options.fallbackErrorDetail, - exitCode: result.exitCode, + detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), stdoutLength: result.stdout.length, stderrLength: result.stderr.length, }); From c557bb10aa7cbb78a50df230d1e8e24af79cb662 Mon Sep 17 00:00:00 2001 From: Koushik_xd <122906171+koushikxd@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:47:15 +0530 Subject: [PATCH 011/111] fix(web): sidebar search matches message content (#11761) --- .../src/components/CommandPaletteResults.tsx | 68 +------------------ apps/web/src/components/Sidebar.logic.test.ts | 41 ++++++++++- apps/web/src/components/Sidebar.logic.ts | 45 +++++++++--- apps/web/src/components/Sidebar.tsx | 63 +++++++++++++++-- apps/web/src/components/ThreadSearchMatch.tsx | 66 ++++++++++++++++++ 5 files changed, 198 insertions(+), 85 deletions(-) create mode 100644 apps/web/src/components/ThreadSearchMatch.tsx diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index bbdbc28b0609..c66ab9a1cf2b 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -14,71 +14,9 @@ import { CommandList, CommandShortcut, } from "./ui/command"; +import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { cn } from "~/lib/utils"; -function foldAsciiCase(value: string): string { - return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); -} - -function HighlightedSearchText(props: { text: string; query: string }) { - const query = props.query.trim(); - if (query.length === 0) return props.text; - - const normalizedText = foldAsciiCase(props.text); - const normalizedQuery = foldAsciiCase(query); - const parts: Array<{ - readonly text: string; - readonly highlighted: boolean; - readonly start: number; - }> = []; - let cursor = 0; - - while (cursor < props.text.length) { - const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); - if (matchIndex === -1) { - parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); - break; - } - if (matchIndex > cursor) { - parts.push({ - text: props.text.slice(cursor, matchIndex), - highlighted: false, - start: cursor, - }); - } - parts.push({ - text: props.text.slice(matchIndex, matchIndex + query.length), - highlighted: true, - start: matchIndex, - }); - cursor = matchIndex + query.length; - } - - return parts.map((part) => - part.highlighted ? ( - - {part.text} - - ) : ( - part.text - ), - ); -} - -function ThreadContentMatch(props: { - match: NonNullable; -}) { - const isUser = props.match.source === "user"; - return ( - - - {isUser ? "You:" : "Agent:"} - {" "} - - - ); -} - interface CommandPaletteResultsProps { emptyStateMessage?: string; groups: ReadonlyArray; @@ -139,7 +77,7 @@ function DisabledCommandPaletteResultRow(props: { {props.item.title} {props.item.threadContentMatch ? ( - + ) : null} {props.item.description ? ( @@ -190,7 +128,7 @@ function CommandPaletteResultRow(props: { {props.item.title} {props.item.threadContentMatch ? ( - + ) : null} {props.item.description ? ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0a1ea308d128..76960f3f533b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -51,6 +51,7 @@ import { type SidebarSection, resolveSidebarDropVerb, } from "./Sidebar.logic"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, OrchestrationLatestTurn, @@ -816,11 +817,23 @@ describe("resolveSidebarThreadStatus", () => { }); describe("searchSidebarThreads", () => { + const searchThread = (id: string, title: string, project: string) => ({ + environmentId: localEnvironmentId, + id: ThreadId.make(id), + title, + project, + }); const threads = [ - { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, - { id: "thread-2", title: "Review providers", project: "Workspace" }, - { id: "thread-3", title: "WORKTREE cleanup", project: "Beta" }, + searchThread("thread-1", "Fix workspace search", "Alpha"), + searchThread("thread-2", "Review providers", "Workspace"), + searchThread("thread-3", "WORKTREE cleanup", "Beta"), ]; + const contentKeys = (...ids: ReadonlyArray) => + new Set( + ids.map((id) => + threadSearchMatchKey({ environmentId: localEnvironmentId, threadId: ThreadId.make(id) }), + ), + ); it("matches thread titles case-insensitively and preserves their order", () => { expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); @@ -833,6 +846,28 @@ describe("searchSidebarThreads", () => { it("returns no results for an empty query", () => { expect(searchSidebarThreads(threads, " ")).toEqual([]); }); + + it("appends content-only matches after every title match", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-2"))).toEqual([ + threads[0], + threads[2], + threads[1], + ]); + }); + + it("lists a thread matching both title and content once", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-1"))).toEqual([ + threads[0], + threads[2], + ]); + }); + + it("ignores content matches for threads outside the sidebar collection", () => { + expect(searchSidebarThreads(threads, "work", contentKeys("thread-missing"))).toEqual([ + threads[0], + threads[2], + ]); + }); }); describe("filterSidebarProjectScopeItems", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 27e47d131e0d..fcedf46ce507 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -5,7 +5,8 @@ import { isAtomCommandInterrupted, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import type { ContextMenuItem } from "@t3tools/contracts"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; @@ -895,21 +896,45 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; +const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set(); + /** - * Search the already-ordered sidebar thread collection by title or linked PR. - * Keeping the input order means lifecycle ordering (active, snoozed, settled) - * remains stable while the user narrows the list. + * Search the already-ordered sidebar thread collection by title or linked PR, + * plus any thread whose messages the server matched (`contentMatchKeys`, keyed + * by `threadSearchMatchKey`). Keeping the input order means lifecycle ordering + * (active, snoozed, settled) remains stable while the user narrows the list. */ export function searchSidebarThreads< - T extends { readonly title: string } & Parameters[0], ->(threads: readonly T[], query: string): T[] { + T extends { + readonly environmentId: EnvironmentId; + readonly id: ThreadId; + readonly title: string; + } & Parameters[0], +>( + threads: readonly T[], + query: string, + contentMatchKeys: ReadonlySet = EMPTY_CONTENT_MATCH_KEYS, +): T[] { const normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.length === 0) return []; - return threads.filter((thread) => - [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + const titleMatches: T[] = []; + const contentMatches: T[] = []; + for (const thread of threads) { + const matchesTitle = [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => term.toLowerCase().includes(normalizedQuery), - ), - ); + ); + if (matchesTitle) { + titleMatches.push(thread); + } else if ( + contentMatchKeys.size > 0 && + contentMatchKeys.has( + threadSearchMatchKey({ environmentId: thread.environmentId, threadId: thread.id }), + ) + ) { + contentMatches.push(thread); + } + } + return [...titleMatches, ...contentMatches]; } export function filterSidebarProjectScopeItems(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b2138148b057..a03196fc3b8d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,10 @@ import { threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { + threadSearchMatchKey, + type EnvironmentThreadSearchMatch, +} from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { parseScopedThreadKey, @@ -134,6 +138,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { useEnvironmentQuery } from "../state/query"; +import { useThreadSearch } from "../state/queries"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, @@ -210,6 +215,7 @@ import { type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; +import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -2001,6 +2007,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { isHighlighted: boolean; isRouteActive: boolean; resultId: string; + searchMatch: EnvironmentThreadSearchMatch | null; + searchQuery: string; onHighlight: () => void; onSelect: () => void; onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; @@ -2091,7 +2099,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onMouseMove={props.onHighlight} onClick={props.onSelect} className={cn( - "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", + "flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1 text-left text-sm outline-none", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2104,9 +2112,22 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { {props.project ? ( ) : null} - {thread.title} - - {threadTimeLabel(thread)} + + + {thread.title} + + {threadTimeLabel(thread)} + + + {props.searchMatch ? ( + + ) : null} [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); + const searchEnvironmentIds = useMemo( + () => + environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId), + [environments], + ); + // useThreadSearch owns the debounce and the two-character floor. + const threadSearch = useThreadSearch(searchEnvironmentIds, threadSearchQuery); + const threadSearchMatchByKey = useMemo( + () => + new Map(threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const)), + [threadSearch.matches], + ); const threadSearchResults = useMemo( - () => searchSidebarThreads(searchableThreads, threadSearchQuery), - [searchableThreads, threadSearchQuery], + () => + searchSidebarThreads( + searchableThreads, + threadSearchQuery, + new Set(threadSearchMatchByKey.keys()), + ), + [searchableThreads, threadSearchQuery, threadSearchMatchByKey], ); const threadSearchResultOrderKey = threadSearchResults .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))) @@ -4594,6 +4634,15 @@ export default function Sidebar() { isHighlighted={activeSearchResultIndex === index} isRouteActive={routeThreadKey === threadKey} resultId={`sidebar-thread-search-result-${index}`} + searchMatch={ + threadSearchMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ) ?? null + } + searchQuery={threadSearchQuery} onHighlight={() => setActiveSearchResultIndex(index)} onSelect={() => selectThreadSearchResult(thread)} onFileDropThreads={handleThreadFileDrop} @@ -4607,7 +4656,7 @@ export default function Sidebar() { role="status" className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground" > - No threads found + {threadSearch.isPending ? "Searching thread messages…" : "No threads found"}

) ) : null} diff --git a/apps/web/src/components/ThreadSearchMatch.tsx b/apps/web/src/components/ThreadSearchMatch.tsx new file mode 100644 index 000000000000..db2e286ae28d --- /dev/null +++ b/apps/web/src/components/ThreadSearchMatch.tsx @@ -0,0 +1,66 @@ +function foldAsciiCase(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function HighlightedSearchText(props: { text: string; query: string }) { + const query = props.query.trim(); + if (query.length === 0) return props.text; + + const normalizedText = foldAsciiCase(props.text); + const normalizedQuery = foldAsciiCase(query); + const parts: Array<{ + readonly text: string; + readonly highlighted: boolean; + readonly start: number; + }> = []; + let cursor = 0; + + while (cursor < props.text.length) { + const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); + if (matchIndex === -1) { + parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); + break; + } + if (matchIndex > cursor) { + parts.push({ + text: props.text.slice(cursor, matchIndex), + highlighted: false, + start: cursor, + }); + } + parts.push({ + text: props.text.slice(matchIndex, matchIndex + query.length), + highlighted: true, + start: matchIndex, + }); + cursor = matchIndex + query.length; + } + + return parts.map((part) => + part.highlighted ? ( + + {part.text} + + ) : ( + part.text + ), + ); +} + +export function ThreadSearchMatchExcerpt(props: { + match: { + readonly source: "user" | "assistant"; + readonly snippet: string; + readonly query: string; + }; +}) { + const isUser = props.match.source === "user"; + return ( + + + {isUser ? "You:" : "Agent:"} + {" "} + + + ); +} From 6d68677ff90915c17de06d78bb498d41a1adfe6a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 11:20:06 -0700 Subject: [PATCH 012/111] fix(server): restore secrets when settings persistence fails (#12487) --- apps/server/src/serverSettings.test.ts | 203 +++++++++++++++++++ apps/server/src/serverSettings.ts | 266 ++++++++++++++++--------- 2 files changed, 372 insertions(+), 97 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 208d75fb6517..bd216a0e88a1 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1184,6 +1184,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); } + for (const sensitiveLast of [true, false]) { + it.effect(`preserves duplicate secret operation order (sensitive last: ${sensitiveLast})`, () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const instanceId = ProviderInstanceId.make("codex_duplicate"); + const secret = { name: "API_TOKEN", value: "secret-last", sensitive: true }; + const plain = { name: "API_TOKEN", value: "plain-last", sensitive: false }; + const next = yield* service.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: sensitiveLast ? [plain, secret] : [secret, plain], + config: {}, + }, + }, + }); + assert.equal( + next.providerInstances[instanceId]?.environment?.at(-1)?.value, + sensitiveLast ? "secret-last" : "plain-last", + ); + assert.equal( + next.providerInstances[instanceId]?.environment?.find((v) => v.sensitive)?.value, + sensitiveLast ? "secret-last" : "", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + } + it.effect("stores sensitive provider instance environment values outside settings.json", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1283,6 +1311,181 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("rolls back provider secret changes when the settings file commit fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + let failRename = false; + let settingsPathToFail: string | undefined; + const writeFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + description: "Forced settings write failure.", + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + rename: (fromPath, toPath) => + failRename && toPath === settingsPathToFail + ? Effect.fail(writeFailure) + : fileSystem.rename(fromPath, toPath), + }); + const instanceId = ProviderInstanceId.make("codex_write_failure"); + const settingsLayer = makeServerSettingsLayer().pipe( + Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, failingFileSystem)), + ); + + yield* Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + settingsPathToFail = (yield* ServerConfig.ServerConfig).settingsPath; + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }], + config: {}, + }, + }, + }); + + failRename = true; + const failedUpdate = yield* serverSettings + .updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }], + config: {}, + }, + }, + }) + .pipe(Effect.result); + assert.equal(failedUpdate._tag, "Failure"); + assert.equal( + (yield* serverSettings.getSettings).providerInstances[instanceId]?.environment?.[0] + ?.value, + "sk-kept", + ); + + const failed = yield* serverSettings + .updateSettings({ providerInstances: {} }) + .pipe(Effect.result); + assert.equal(failed._tag, "Failure"); + assert.equal( + (yield* serverSettings.getSettings).providerInstances[instanceId]?.environment?.[0] + ?.value, + "sk-kept", + ); + }).pipe(Effect.provide(settingsLayer)); + }), + ); + + for (const failure of ["response materialization", "partially committed write"] as const) { + it.effect(`rolls back provider secret changes after ${failure} fails`, () => { + const textDecoder = new TextDecoder(); + const secrets = new Map(); + let rejectNewSecret = false; + const secretStoreLayer = Layer.succeed( + ServerSecretStore.ServerSecretStore, + ServerSecretStore.ServerSecretStore.of({ + get: (name) => + Effect.suspend(() => { + const value = secrets.get(name); + if ( + failure === "response materialization" && + rejectNewSecret && + value !== undefined && + textDecoder.decode(value) === "sk-new" + ) { + return Effect.fail( + new ServerSecretStore.SecretStoreReadError({ + resource: `secret ${name}`, + cause: "Forced response materialization failure.", + }), + ); + } + return Effect.succeed( + value === undefined ? Option.none() : Option.some(Uint8Array.from(value)), + ); + }), + set: (name, value) => + Effect.suspend(() => { + secrets.set(name, Uint8Array.from(value)); + return failure === "partially committed write" && + rejectNewSecret && + textDecoder.decode(value) === "sk-new" + ? Effect.fail( + new ServerSecretStore.SecretStorePersistError({ + resource: `secret ${name}`, + cause: "chmod failed after rename", + }), + ) + : Effect.void; + }), + create: (name, value) => + Effect.sync(() => { + secrets.set(name, Uint8Array.from(value)); + }), + getOrCreateRandom: (name, bytes) => + Effect.sync(() => { + const value = secrets.get(name) ?? new Uint8Array(bytes); + secrets.set(name, value); + return Uint8Array.from(value); + }), + remove: (name) => + Effect.sync(() => { + secrets.delete(name); + }), + }), + ); + const settingsLayer = ServerSettingsModule.layer.pipe( + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provide(secretStoreLayer), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-materialization-failure-test-", + }), + ), + ), + ); + const instanceId = ProviderInstanceId.make("codex_materialization_failure"); + + return Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }], + config: {}, + }, + }, + }); + + rejectNewSecret = true; + const failedUpdate = yield* serverSettings + .updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }], + config: {}, + }, + }, + }) + .pipe(Effect.result); + + assert.equal(failedUpdate._tag, "Failure"); + rejectNewSecret = false; + assert.equal( + (yield* serverSettings.getSettings).providerInstances[instanceId]?.environment?.[0] + ?.value, + "sk-kept", + ); + }).pipe(Effect.provide(settingsLayer)); + }); + } + it.effect("folds legacy project overrides into projectSettingsOverrides once", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 50f8649eaacb..ed52282c0237 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -733,14 +733,21 @@ const make = Effect.gen(function* () { Stream.map(resolveTextGenerationProvider), ); - const persistProviderEnvironmentSecrets = ( - current: ServerSettings, - next: ServerSettings, - ): Effect.Effect => - Effect.gen(function* () { + type SecretChange = { + readonly secretName: string; + readonly providerInstanceId?: string; + readonly environmentVariable?: string; + } & ( + | { readonly kind: "write"; readonly value: Uint8Array } + | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" } + ); + + const persistProviderEnvironmentSecrets = (current: ServerSettings, next: ServerSettings) => + Effect.sync(() => { const providerInstances: Record = { ...next.providerInstances, }; + const changes: SecretChange[] = []; const nextSecretKeys = new Set(); for (const [instanceId, instance] of Object.entries(next.providerInstances)) { @@ -749,18 +756,13 @@ const make = Effect.gen(function* () { for (const variable of instance.environment) { const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (!variable.sensitive) { - yield* secretStore.remove(secretName).pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "remove-secret", - providerInstanceId: instanceId, - environmentVariable: variable.name, - cause, - }), - ), - ); + changes.push({ + kind: "remove", + secretName, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); environment.push(redactProviderEnvironmentVariable(variable)); continue; } @@ -779,32 +781,22 @@ const make = Effect.gen(function* () { const value = inlineValue ?? variable.value; if (!variable.valueRedacted || inlineValue !== undefined) { if (value.length > 0) { - yield* secretStore.set(secretName, textEncoder.encode(value)).pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "write-secret", - providerInstanceId: instanceId, - environmentVariable: variable.name, - cause, - }), - ), - ); + changes.push({ + kind: "write", + secretName, + value: textEncoder.encode(value), + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); environment.push({ ...variable, value: "", valueRedacted: true }); } else { - yield* secretStore.remove(secretName).pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "remove-secret", - providerInstanceId: instanceId, - environmentVariable: variable.name, - cause, - }), - ), - ); + changes.push({ + kind: "remove", + secretName, + operation: "remove-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); const { valueRedacted: _omit, ...rest } = variable; environment.push(rest); } @@ -824,18 +816,13 @@ const make = Effect.gen(function* () { if (!variable.sensitive) continue; const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); if (nextSecretKeys.has(secretName)) continue; - yield* secretStore.remove(secretName).pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "remove-stale-secret", - providerInstanceId: instanceId, - environmentVariable: variable.name, - cause, - }), - ), - ); + changes.push({ + kind: "remove", + secretName, + operation: "remove-stale-secret", + providerInstanceId: instanceId, + environmentVariable: variable.name, + }); } } @@ -843,51 +830,151 @@ const make = Effect.gen(function* () { for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { const secretName = usageLimitSourceSecretName(sourceId); if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { - // Unchanged from the client's point of view; the store already has it. usageLimitSources[sourceId] = source; continue; } if (source.managementKey.length === 0) { - yield* secretStore - .remove(secretName) - .pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ settingsPath, operation: "remove-secret", cause }), - ), - ); + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); usageLimitSources[sourceId] = source; continue; } - yield* secretStore - .set(secretName, textEncoder.encode(source.managementKey)) - .pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ settingsPath, operation: "write-secret", cause }), - ), - ); + changes.push({ + kind: "write", + secretName, + value: textEncoder.encode(source.managementKey), + }); usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; } for (const sourceId of Object.keys(current.usageLimitSources)) { if (sourceId in next.usageLimitSources) continue; - yield* secretStore - .remove(usageLimitSourceSecretName(sourceId)) - .pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ settingsPath, operation: "remove-stale-secret", cause }), - ), - ); + changes.push({ + kind: "remove", + secretName: usageLimitSourceSecretName(sourceId), + operation: "remove-stale-secret", + }); } return { - ...next, - providerInstances: providerInstances as ServerSettings["providerInstances"], - usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + settings: { + ...next, + providerInstances: providerInstances as ServerSettings["providerInstances"], + usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + }, + changes, }; }); + const rollbackProviderEnvironmentSecretWrites = ( + writes: ReadonlyArray<{ + readonly secretName: string; + readonly previousValue: Option.Option; + readonly providerInstanceId?: string; + readonly environmentVariable?: string; + }>, + ) => + Effect.forEach( + writes.toReversed(), + (write) => + (Option.isSome(write.previousValue) + ? secretStore.set(write.secretName, write.previousValue.value) + : secretStore.remove(write.secretName) + ).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to roll back provider environment secret", { + providerInstanceId: write.providerInstanceId, + environmentVariable: write.environmentVariable, + cause, + }), + ), + ), + { discard: true }, + ); + + const applyProviderEnvironmentSecretChanges = (changes: ReadonlyArray) => { + const applied: Array<{ + readonly secretName: string; + readonly previousValue: Option.Option; + readonly providerInstanceId?: string; + readonly environmentVariable?: string; + }> = []; + const rollback = Effect.suspend(() => rollbackProviderEnvironmentSecretWrites(applied)); + return Effect.forEach( + changes, + (change) => + Effect.gen(function* () { + const previousValue = yield* secretStore.get(change.secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret", + providerInstanceId: change.providerInstanceId, + environmentVariable: change.environmentVariable, + cause, + }), + ), + ); + // A store operation may mutate before reporting an error (for example chmod after rename). + applied.push({ ...change, previousValue }); + yield* ( + change.kind === "write" + ? secretStore.set(change.secretName, change.value) + : secretStore.remove(change.secretName) + ).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: change.kind === "write" ? "write-secret" : change.operation, + providerInstanceId: change.providerInstanceId, + environmentVariable: change.environmentVariable, + cause, + }), + ), + ); + }), + { discard: true }, + ).pipe( + Effect.tapError(() => rollback), + Effect.as(rollback), + ); + }; + + const updateSettings = ( + patch: ServerSettingsPatch, + ): Effect.Effect => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* getSettingsFromCache; + const updated = applyServerSettingsPatch(current, patch); + const persisted = yield* persistProviderEnvironmentSecrets(current, updated); + const next = yield* normalizeServerSettings(persisted.settings); + const materialized = yield* Effect.uninterruptibleMask(() => + Effect.gen(function* () { + const rollbackSecretChanges = yield* applyProviderEnvironmentSecretChanges( + persisted.changes, + ); + const materializedExit = yield* Effect.exit( + materializeProviderEnvironmentSecrets(next), + ); + if (Exit.isFailure(materializedExit)) { + yield* rollbackSecretChanges; + return yield* Effect.failCause(materializedExit.cause); + } + const writeExit = yield* Effect.exit(writeSettingsAtomically(next)); + if (Exit.isFailure(writeExit)) { + yield* rollbackSecretChanges; + return yield* Effect.failCause(writeExit.cause); + } + return materializedExit.value; + }), + ); + yield* Cache.set(settingsCache, cacheKey, next); + yield* emitChange(next); + return resolveTextGenerationProvider(materialized); + }), + ); + const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* Cache.invalidate(settingsCache, cacheKey); @@ -963,22 +1050,7 @@ const make = Effect.gen(function* () { Effect.flatMap(materializeProviderEnvironmentSecrets), Effect.map(resolveTextGenerationProvider), ), - updateSettings: (patch) => - writeSemaphore.withPermits(1)( - Effect.gen(function* () { - const current = yield* getSettingsFromCache; - const nextPersisted = yield* persistProviderEnvironmentSecrets( - current, - applyServerSettingsPatch(current, patch), - ); - const next = yield* normalizeServerSettings(nextPersisted); - yield* writeSettingsAtomically(next); - yield* Cache.set(settingsCache, cacheKey, next); - yield* emitChange(next); - const materialized = yield* materializeProviderEnvironmentSecrets(next); - return resolveTextGenerationProvider(materialized); - }), - ), + updateSettings, get streamChanges() { return materializeChanges(Stream.fromPubSub(changesPubSub)); }, From 8ebb6112377a110afb956ac01fb44fee68f5709c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 11:23:34 -0700 Subject: [PATCH 013/111] fix(ci): accept V2 transfer reports without cross-scenario comparisons (#12492) --- .github/scripts/thread-transfer-report.cjs | 4 ++-- .../scripts/thread-transfer-report.test.cjs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 94a02b7806dc..264a92b1c8c2 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -61,7 +61,7 @@ function validateResult(value) { } assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); - if (value.scenario.id !== "thread-transfer-v1") { + if (!["thread-transfer-v1", "thread-transfer-v2"].includes(value.scenario.id)) { throw new Error("result.scenario.id is not supported"); } for (const key of SCENARIO_KEYS.slice(1)) { @@ -157,7 +157,7 @@ function renderComment(input) { `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, ); - if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + if (comparable && baseline.providers[provider].ceiling[metric.key] !== ceiling) { ceilingChanges.push( `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, ); diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 4935864e46f0..3c8ab2578a55 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -290,3 +290,21 @@ test("preserves a successful result when a same-SHA rerun has no artifact", asyn assert.equal(published, true); assert.equal(updatedComment, false); }); + +test("accepts V2 without comparing it to the V1 scenario", () => { + const current = result(); + current.scenario.id = "thread-transfer-v2"; + current.providers.codex.ceiling = { ...current.providers.codex.ceiling, totalWireBytes: 3000000 }; + assert.equal(validateResult(current), current); + const comment = renderComment({ + current, + baseline: result(), + currentRun: { sha: "bbbbbbbb", conclusion: "success", url: "https://example.com/current" }, + baselineRun: { sha: "aaaaaaaa", matchesBase: true, url: "https://example.com/baseline" }, + }); + assert.match(comment, /fixture changed/); + assert.doesNotMatch(comment, /This PR changes transfer ceilings/); + assert.doesNotMatch(comment, /[+-]\d+\.\d+%/); + current.scenario.id = "unrecognized"; + assert.throws(() => validateResult(current), /not supported/); +}); From e84f23a97d587e0e58215f1fba2eab12717bec28 Mon Sep 17 00:00:00 2001 From: Dominic Roy Date: Fri, 18 Sep 2026 14:35:47 -0400 Subject: [PATCH 014/111] fix(web): speed up PR previews with fewer GitHub requests (#11825) Co-authored-by: Julius Marminge --- apps/server/scripts/measure-pr-preview.ts | 128 ++++ apps/server/src/auth/RpcAuthorization.ts | 1 + .../pullRequest/GitHubPullRequestCli.test.ts | 550 +++++++++++++++--- .../src/pullRequest/GitHubPullRequestCli.ts | 316 +++++----- .../GitHubPullRequestProvider.test.ts | 149 +++-- .../pullRequest/GitHubPullRequestProvider.ts | 199 +++---- .../src/pullRequest/PullRequestProvider.ts | 9 + .../pullRequest/PullRequestService.test.ts | 219 ++++++- .../src/pullRequest/PullRequestService.ts | 61 ++ .../pullRequest/gitHubPullRequestJson.test.ts | 58 +- .../src/pullRequest/gitHubPullRequestJson.ts | 222 +++++-- apps/server/src/ws.ts | 6 + apps/web/src/components/ChatMarkdown.tsx | 1 + .../pullRequest/PullRequestLinkPreview.tsx | 63 +- knip.jsonc | 1 + .../src/state/pullRequestRouting.ts | 1 + .../src/state/pullRequests.test.ts | 78 +++ .../client-runtime/src/state/pullRequests.ts | 18 + packages/contracts/src/pullRequest.ts | 14 + packages/contracts/src/rpc.ts | 9 + 20 files changed, 1632 insertions(+), 471 deletions(-) create mode 100644 apps/server/scripts/measure-pr-preview.ts diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts new file mode 100644 index 000000000000..b7938edcac76 --- /dev/null +++ b/apps/server/scripts/measure-pr-preview.ts @@ -0,0 +1,128 @@ +// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 +// Numbers form a session with shared repository-permission caches. Browser and +// service caches are excluded. Uses real GitHub reads, without a server or database. +// CLI-generated GraphQL +// queries are replayed with rateLimit.cost, outside the timed section, to measure +// their cost without confusing other applications' traffic with this process's. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Console from "effect/Console"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; +import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; +import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; + +const [repository, ...numbers] = process.argv.slice(2); +if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) { + throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number..."); +} + +type Read = { graphqlRequests: number; restRequests: number; cost: number; debug: string }; +const reads: Read[] = []; +const measuredProcess = Layer.effect( + VcsProcess.VcsProcess, + Effect.gen(function* () { + const vcs = yield* VcsProcess.VcsProcess; + return VcsProcess.VcsProcess.of({ + run: (input) => + vcs.run({ ...input, env: { ...input.env, GH_DEBUG: input.env?.GH_DEBUG ?? "api" } }).pipe( + Effect.tap((output) => + Effect.sync(() => { + const graphqlRequests = [...output.stderr.matchAll(/^> POST \/graphql /gm)].length; + const requests = [...output.stderr.matchAll(/^> (?:GET|POST) /gm)].length; + const cost = /"rateLimit"\s*:\s*\{[^}]*"cost"\s*:\s*(\d+)/.exec(output.stdout)?.[1]; + reads.push({ + graphqlRequests, + restRequests: requests - graphqlRequests, + cost: Number(cost ?? 0), + debug: output.stderr, + }); + }), + ), + ), + }); + }), +).pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer)); + +const services = GitHubPullRequestCli.layer.pipe( + Layer.provide(GitHubCli.layer), + Layer.provideMerge(measuredProcess), +); + +const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const graphqlCost = Effect.fn("measurePrPreview.graphqlCost")(function* (read: Read) { + // The REST quota probe synthesizes rateLimit.cost for budget admission. + // It does not spend GraphQL points. + if (!read.graphqlRequests) return 0; + if (read.cost) return read.cost; + const vcs = yield* VcsProcess.VcsProcess; + let cost = 0; + let found = 0; + for (const match of read.debug.matchAll( + /GraphQL query:\n([\s\S]*?)\nGraphQL variables: (\{[^\n]*\})/g, + )) { + const query = match[1]!.replace(/(\bquery\b[^{]*\{)/, "$1 rateLimit { cost }"); + const output = yield* vcs.run({ + operation: "measurePrPreview.cost", + command: "gh", + cwd: process.cwd(), + args: [ + "api", + "graphql", + "--hostname", + "github.com", + "--input", + "-", + "--jq", + ".data.rateLimit.cost", + ], + stdin: encodeJson({ query, variables: decodeJson(match[2]!) }), + env: { GH_DEBUG: "" }, + }); + cost += Number(output.stdout.trim()); + found++; + } + if (found !== read.graphqlRequests) + throw new Error("Could not account for every GraphQL request"); + return cost; +}); + +for (const mode of ["detail", "preview"] as const) { + // Start each side cold, then retain the caches shared across PRs in one session. + const rows = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const provider = yield* GitHubPullRequestProvider.make; + return yield* Effect.forEach(numbers.map(Number), (number) => + Effect.gen(function* () { + const input = { cwd: process.cwd(), repository, host: "github.com", number }; + reads.length = 0; + const start = performance.now(); + const value = yield* mode === "detail" + ? provider.getChangeRequest(input) + : provider.getChangeRequestPreview!(input); + const elapsedMs = Math.round(performance.now() - start); + const measured = [...reads]; + const costs = yield* Effect.forEach(measured, graphqlCost); + const points = costs.reduce((total, cost) => total + cost, 0); + return { + repository, + number, + mode, + state: value.state, + elapsedMs, + points, + graphqlRequests: measured.reduce((total, read) => total + read.graphqlRequests, 0), + restRequests: measured.reduce((total, read) => total + read.restRequests, 0), + }; + }), + ); + }), + ).pipe(Effect.provide(services)), + ); + for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row))); +} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index b9ae42f77e76..20f2893b8335 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -73,6 +73,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 23c14da3b48c..fd5ab04e8a81 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,5 +1,6 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -16,6 +17,36 @@ import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const coreResponse = (pullRequest: Readonly> = {}) => ({ + data: { + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerPermission: "WRITE", + pullRequest: { + number: 7, + title: "Pull request 7", + url: "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/acme/web/pull/7", + headRefName: "feature", + baseRefName: "main", + headRefOid: "abc123", + state: "OPEN", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + viewerCanUpdate: true, + viewerDidAuthor: false, + viewerCanUpdateBranch: true, + baseRef: { compare: { behindBy: 2 } }, + reviewRequests: { nodes: [] }, + labels: { nodes: [] }, + commits: { nodes: [] }, + ...pullRequest, + }, + }, + }, +}); + const mockedExecute = vi.fn(); const mockedStackMemberships = vi.fn(() => Effect.succeed(output('{"data":{}}')), @@ -33,7 +64,7 @@ const layer = it.layer( getPullRequest: mockedGetPullRequest, }), ), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubGraphQlBudget.layer), ), ); @@ -255,6 +286,118 @@ it.effect( ); layer("GitHubPullRequestCli.layer", (it) => { + it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const resetAt = "2099-08-13T14:00:00Z"; + yield* budget.observe( + "preview-budget.example", + encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), + ); + mockedExecute.mockReturnValue( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + number: 7, + title: "Fast previews", + url: "https://preview-budget.example/acme/web/pull/7", + state: "OPEN", + isDraft: false, + createdAt: "2026-07-01T00:00:00Z", + author: null, + }, + }, + rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const input = { + cwd: "/w", + repository: "acme/web", + host: "preview-budget.example", + number: 7, + }; + const results = yield* Effect.all( + Array.from({ length: 20 }, (_, index) => + cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), + ), + { concurrency: "unbounded" }, + ); + expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); + for (const result of results) { + if (result._tag === "Failure") { + expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); + } + } + expect(mockedExecute).toHaveBeenCalledTimes(1); + yield* TestClock.setTime(Date.parse(resetAt)); + yield* cli.getPullRequestPreview(input); + expect(mockedExecute).toHaveBeenCalledTimes(2); + yield* TestClock.setTime(now); + }), + ); + + it.effect("loads the complete hover card with one GraphQL request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + repository: { + pullRequest: { + number: 7, + title: "Fast previews", + url: "https://github.example/acme/web/pull/7", + state: "MERGED", + isDraft: false, + createdAt: "2026-07-01T00:00:00Z", + author: { + login: "octocat", + name: "Octo Cat", + avatarUrl: "https://github.example/avatar.png", + }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const preview = yield* cli.getPullRequestPreview({ + cwd: "/w", + repository: "acme/web", + host: "github.example", + number: 7, + }); + expect(preview).toEqual({ + number: 7, + title: "Fast previews", + url: "https://github.example/acme/web/pull/7", + state: "merged", + isDraft: false, + createdAt: "2026-07-01T00:00:00Z", + author: { + login: "octocat", + name: "Octo Cat", + avatarUrl: "https://github.example/avatar.png", + }, + }); + expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(callAt(0).args).toEqual( + expect.arrayContaining(["api", "graphql", "--hostname", "github.example"]), + ); + }), + ); + it.effect("coalesces concurrent identity verification for the same host and credential", () => Effect.gen(function* () { mockedExecute.mockImplementation((input) => @@ -1741,19 +1884,20 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify({ - number: 7, - title: "Pull request 7", - url: "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/acme/web/pull/7", - headRefName: "feat/page", - headRefOid: "abc123", - isCrossRepository: false, - headRepositoryOwner: { login: "acme" }, - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - }), + encodeJson( + coreResponse({ + number: 7, + title: "Pull request 7", + url: "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/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: false, + headRepositoryOwner: { login: "acme" }, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }), + ), ), ), ); @@ -1774,19 +1918,20 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("finds and approves every workflow waiting on a maintainer", () => Effect.gen(function* () { const detail = output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify({ - number: 7, - title: "Pull request 7", - url: "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/acme/web/pull/7", - headRefName: "feat/page", - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - }), + encodeJson( + coreResponse({ + number: 7, + title: "Pull request 7", + url: "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/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }), + ), ); const heads = output( // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. @@ -1902,7 +2047,7 @@ layer("GitHubPullRequestCli.layer", (it) => { updatedAt: "2026-07-02T00:00:00Z", }; for (const value of [ - detail, + coreResponse(detail), [ { number: 7, @@ -1912,7 +2057,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }, ], [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }], - { ...detail, headRefOid: "def456" }, + coreResponse({ ...detail, headRefOid: "def456" }), ]) { mockedExecute.mockReturnValueOnce( Effect.succeed( @@ -1943,8 +2088,47 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("reads workflow runs and their pull request scope concurrently", () => + Effect.gen(function* () { + const headsStarted = yield* Deferred.make(); + const runsStarted = yield* Deferred.make(); + mockedExecute.mockImplementation(({ args }) => + args[0] === "pr" + ? Deferred.succeed(headsStarted, undefined).pipe( + Effect.andThen(Deferred.await(runsStarted)), + Effect.as( + output( + '[{"number":7,"headRefOid":"abc123","isCrossRepository":true,"headRepositoryOwner":{"login":"octocat"}}]', + ), + ), + ) + : Deferred.succeed(runsStarted, undefined).pipe( + Effect.andThen(Deferred.await(headsStarted)), + Effect.as( + output('[{"databaseId":10,"workflowName":"build","url":"https://example.com/10"}]'), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const runs = yield* cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }); + + expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); + }), + ); + it.effect("refuses workflow approval when one head belongs to several pull requests", () => Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); mockedExecute.mockReturnValueOnce( Effect.succeed( output( @@ -1983,7 +2167,7 @@ layer("GitHubPullRequestCli.layer", (it) => { limit: 1_000, }); expect(error.detail).toContain("instead of uniquely matching #7"); - expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(mockedExecute).toHaveBeenCalledTimes(2); }), ); @@ -1992,19 +2176,20 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify({ - number: 7, - title: "Pull request 7", - url: "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/acme/web/pull/7", - headRefName: "feat/page", - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: null, - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - }), + encodeJson( + coreResponse({ + number: 7, + title: "Pull request 7", + url: "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/acme/web/pull/7", + headRefName: "feat/page", + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: null, + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + }), + ), ), ), ); @@ -3041,19 +3226,20 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - number: 7, - title: "Progressive detail", - url: "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/acme/web/pull/7", - author: { login: "octocat" }, - headRefName: "feature", - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - body: "Core body", - changedFiles: 2, - }), + encodeJson( + coreResponse({ + number: 7, + title: "Progressive detail", + url: "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/acme/web/pull/7", + author: { login: "octocat" }, + headRefName: "feature", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + body: "Core body", + changedFiles: 2, + }), + ), ), ), ); @@ -3083,13 +3269,216 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(detail.body).toBe("Core body"); expect(activity.author?.login).toBe("octocat"); - expect(callAt(0).args.at(-1)).toBe( - "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest", - ); + expect(callAt(0).args).toContain("headRef=refs/pull/7/head"); + expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch"); + expect(detail.viewerAccess.mergeCapabilities).toEqual({ + merge: true, + squash: false, + rebase: true, + }); + expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); }), ); + it.effect("decodes reviewers, labels and workflow checks without another detail read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + coreResponse({ + baseRef: null, + reviewRequests: { + nodes: [ + { requestedReviewer: { login: "reviewer" } }, + { requestedReviewer: { slug: "maintainers", name: "Maintainers" } }, + ], + }, + labels: { nodes: [{ name: "bug", color: "ff0000" }] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [ + { + __typename: "CheckRun", + name: "build", + status: "COMPLETED", + conclusion: "SUCCESS", + checkSuite: { workflowRun: { workflow: { name: "linux" } } }, + }, + { + __typename: "CheckRun", + name: "build", + status: "COMPLETED", + conclusion: "FAILURE", + checkSuite: { workflowRun: { workflow: { name: "windows" } } }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + }, + }, + }, + ], + }, + }), + ), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const detail = yield* cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + expect(mockedExecute).toHaveBeenCalledOnce(); + expect(detail.comparison).toBeNull(); + expect(detail.reviewRequestLogins).toEqual(["reviewer"]); + expect(detail.hasTeamReviewRequest).toBe(true); + expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); + expect(detail.checks).toHaveLength(2); + expect(detail.checksState).toBe("failing"); + }), + ); + + it.effect("reads every check when the combined response has another page", () => + Effect.gen(function* () { + const response = coreResponse({ + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], + pageInfo: { hasNextPage: true }, + }, + }, + }, + }, + ], + }, + }); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + ...response.data.repository.pullRequest, + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, + ], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const detail = yield* cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + expect(detail.checks).toHaveLength(2); + expect(detail.checksState).toBe("failing"); + expect(detail.checksTruncated).toBe(false); + expect(callAt(1).args.slice(0, 2)).toEqual(["pr", "view"]); + }), + ); + + it.effect("refuses to combine checks from different head revisions", () => + Effect.gen(function* () { + const response = coreResponse({ + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [], + pageInfo: { hasNextPage: true }, + }, + }, + }, + }, + ], + }, + }); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + ...response.data.repository.pullRequest, + headRefOid: "new-head", + reviewRequests: [], + labels: [], + statusCheckRollup: [], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const error = yield* Effect.flip( + cli.getPullRequestDetail({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + expect(error._tag).toBe("GitHubPullRequestReadError"); + }), + ); + + it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => + Effect.gen(function* () { + const response = coreResponse(); + mockedExecute.mockReturnValue( + Effect.succeed( + output( + encodeJson({ + ...response, + data: { + ...response.data, + rateLimit: { + cost: 1, + limit: 5000, + remaining: 500, + resetAt: "2099-08-13T14:00:00Z", + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const input = { + cwd: "/w", + repository: "acme/web", + host: "github.core-reserve.test", + number: 7, + }; + yield* cli.getPullRequestDetail(input); + const error = yield* Effect.flip(cli.getPullRequestDetail(input)); + expect(error._tag).toBe("SourceControlRateLimitPausedError"); + expect(mockedExecute).toHaveBeenCalledOnce(); + yield* cli + .getPullRequestDetail(input) + .pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); + expect(mockedExecute).toHaveBeenCalledTimes(2); + }), + ); + it.effect("fails a files page too large to read rather than calling the diff whole", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); @@ -3271,6 +3660,9 @@ layer("GitHubPullRequestCli.layer", (it) => { JSON.stringify({ data: { repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, @@ -3291,7 +3683,11 @@ layer("GitHubPullRequestCli.layer", (it) => { // One request, because both answers hang off the same repository object. assert.strictEqual(mockedExecute.mock.calls.length, 1); expect(callAt(0).args).toContain("number=7"); + expect(callAt(0).args.at(-1)).toContain( + "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed", + ); expect(access).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, canWrite: false, canTriage: false, canUpdate: true, @@ -3433,6 +3829,9 @@ layer("GitHubPullRequestCli.layer", (it) => { JSON.stringify({ data: { repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, @@ -3460,6 +3859,7 @@ layer("GitHubPullRequestCli.layer", (it) => { assert.strictEqual(mockedExecute.mock.calls.length, 2); expect(access).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, canWrite: false, canTriage: false, canUpdate: true, @@ -3469,38 +3869,6 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("reads the viewer's role off the same call as the merge settings", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - mergeCommitAllowed: false, - squashMergeAllowed: true, - rebaseMergeAllowed: true, - viewerPermission: "WRITE", - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const access = yield* cli.getRepositoryAccess({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(callAt(0).args).toContain( - "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission", - ); - assert.isTrue(access.canWrite); - expect(access.mergeCapabilities).toEqual({ merge: false, squash: true, rebase: true }); - }), - ); - it.effect("asks GitHub to review, naming the collection a request is added to", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index d68d77c38f6c..723fcfc4d9b9 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -30,6 +30,7 @@ import { type PullRequestLabelCandidateList, type PullRequestThreadCommentsResult, type PullRequestUpdateMethod, + type PullRequestPreview, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; @@ -44,6 +45,11 @@ import { decodeActorAvatarsJson, decodePullRequestActivityJson, decodePullRequestDetailJson, + decodePullRequestCoreJson, + PULL_REQUEST_CORE_GRAPHQL_QUERY, + type GitHubPullRequestCore, + decodePullRequestPreviewJson, + PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, decodePullRequestFilesJson, decodePullRequestFilesViewedJson, decodePullRequestHeadsJson, @@ -53,7 +59,6 @@ import { decodePullRequestStacksJson, decodePullRequestStatsJson, decodeReactionSubjectScopeJson, - decodeRepositoryAccessJson, decodeReviewerCandidatesJson, decodeLabelCandidatesJson, buildLabelRequestJson, @@ -78,7 +83,6 @@ import { REMOVE_REACTION_GRAPHQL_MUTATION, REVERT_PULL_REQUEST_GRAPHQL_MUTATION, gitHubReactionContent, - REPOSITORY_ACCESS_JSON_FIELDS, RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, REVIEWER_CANDIDATES_GRAPHQL_QUERY, REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, @@ -94,7 +98,6 @@ import { decodeViewerPermissionsJson, decodeWorkflowRunApprovalsJson, type GitHubBaseComparison, - type GitHubPullRequestDetail, type GitHubPullRequestActivity, type GitHubPullRequestHead, type GitHubPullRequestListItem, @@ -519,7 +522,17 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; + + readonly getPullRequestPreview: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect< + Omit, + GitHubPullRequestCliError + >; readonly listWorkflowRunsRequiringApproval: (input: { readonly cwd: string; @@ -639,13 +652,6 @@ export class GitHubPullRequestCli extends Context.Service< readonly cursor: string; }) => Effect.Effect; - /** One `gh repo view`, which answers what the repository allows and where the viewer stands. */ - readonly getRepositoryAccess: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - }) => Effect.Effect; - /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ readonly getViewerAccess: (input: { readonly cwd: string; @@ -654,7 +660,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; /** Manual action checks may use the quota held back from automatic reads. */ readonly allowReserve?: boolean | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** Who this pull request may be sent to, and who it has already been sent to. */ readonly listReviewerCandidates: (input: { @@ -1514,7 +1520,9 @@ export const make = Effect.gen(function* () { return { oldContents, newContents }; }); - const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => + const readLegacyDetail = ( + input: Parameters[0], + ) => github .execute({ cwd: input.cwd, @@ -1543,6 +1551,53 @@ export const make = Effect.gen(function* () { }), ); + const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return GitHubCli.AllowGitHubReserve.pipe( + Effect.flatMap((allowReserve) => + graphqlRead({ + allowReserve, + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ["-f", `headRef=refs/pull/${input.number}/head`], + ], + query: PULL_REQUEST_CORE_GRAPHQL_QUERY, + decode: decodePullRequestCoreJson, + }), + ), + ).pipe( + Effect.flatMap((core) => { + if (!core.checksTruncated) return Effect.succeed(core); + // gh already pages check contexts. Keep its complete, deduplicated result for + // large check suites instead of letting the first 100 checks imply success. + return readLegacyDetail(input).pipe( + Effect.flatMap((detail) => + detail.headSha !== core.headSha + ? Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDetail", + cause: new Error("Pull request head changed while reading checks."), + }), + ) + : Effect.succeed({ + ...core, + checks: detail.checks, + checksState: detail.checksState, + checksTruncated: false, + }), + ), + ); + }), + ); + }; + const workflowApprovalLimit = 1_000; const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1); const workflowApprovalReadError = (cwd: string, cause: unknown) => @@ -1554,71 +1609,74 @@ export const make = Effect.gen(function* () { }); const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - ...repositoryArgs(input), - "--state", - "open", - "--head", - input.headBranch, - "--limit", - workflowApprovalProbeLimit, - "--json", - "number,headRefOid,isCrossRepository,headRepositoryOwner", - ], - }) - .pipe( - Effect.flatMap( - ( - result, - ): Effect.Effect< - GitHubPullRequestHead, - GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError - > => { - const decoded = decodePullRequestHeadsJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); - } - const exactHeads = decoded.success.filter( - (pullRequest) => - pullRequest.headSha === input.headSha && - pullRequest.isCrossRepository === true && - pullRequest.headRepositoryOwner?.toLowerCase() === - input.headRepositoryOwner.toLowerCase(), - ); - if (decoded.success.length > workflowApprovalLimit) { - return Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "head-list-truncated", - observedCount: decoded.success.length, - limit: workflowApprovalLimit, - }), - ); - } - if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { - return Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "head-not-unique", - observedCount: exactHeads.length, - limit: workflowApprovalLimit, - }), - ); - } - return Effect.succeed(exactHeads[0]); - }, - ), - Effect.flatMap(() => - github.execute({ + Effect.all( + [ + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + ...repositoryArgs(input), + "--state", + "open", + "--head", + input.headBranch, + "--limit", + workflowApprovalProbeLimit, + "--json", + "number,headRefOid,isCrossRepository,headRepositoryOwner", + ], + }) + .pipe( + Effect.flatMap( + ( + result, + ): Effect.Effect< + GitHubPullRequestHead, + GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError + > => { + const decoded = decodePullRequestHeadsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); + } + const exactHeads = decoded.success.filter( + (pullRequest) => + pullRequest.headSha === input.headSha && + pullRequest.isCrossRepository === true && + pullRequest.headRepositoryOwner?.toLowerCase() === + input.headRepositoryOwner.toLowerCase(), + ); + if (decoded.success.length > workflowApprovalLimit) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "head-list-truncated", + observedCount: decoded.success.length, + limit: workflowApprovalLimit, + }), + ); + } + if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "head-not-unique", + observedCount: exactHeads.length, + limit: workflowApprovalLimit, + }), + ); + } + return Effect.succeed(exactHeads[0]); + }, + ), + ), + github + .execute({ cwd: input.cwd, args: [ "run", @@ -1637,34 +1695,37 @@ export const make = Effect.gen(function* () { "--json", "databaseId,workflowName,url", ], - }), - ), - Effect.flatMap( - ( - result, - ): Effect.Effect< - ReadonlyArray, - GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError - > => { - const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); - } - return decoded.success.length > workflowApprovalLimit - ? Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "run-list-truncated", - observedCount: decoded.success.length, - limit: workflowApprovalLimit, - }), - ) - : Effect.succeed(decoded.success); - }, - ), - ); + }) + .pipe( + Effect.flatMap( + ( + result, + ): Effect.Effect< + ReadonlyArray, + GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError + > => { + const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); + } + return decoded.success.length > workflowApprovalLimit + ? Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "run-list-truncated", + observedCount: decoded.success.length, + limit: workflowApprovalLimit, + }), + ) + : Effect.succeed(decoded.success); + }, + ), + ), + ], + { concurrency: 2 }, + ).pipe(Effect.map(([, runs]) => runs)); return GitHubPullRequestCli.of({ withVerifiedCredential, @@ -1924,6 +1985,21 @@ export const make = Effect.gen(function* () { ), getPullRequestDetail, + getPullRequestPreview: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestPreview", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, + decode: decodePullRequestPreviewJson, + }); + }, listWorkflowRunsRequiringApproval, getPullRequestStack: (input) => { @@ -2246,34 +2322,6 @@ export const make = Effect.gen(function* () { }); }, - getRepositoryAccess: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "repo", - "view", - `${input.host}/${input.repository}`, - "--json", - REPOSITORY_ACCESS_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodeRepositoryAccessJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getRepositoryAccess", - cause: decoded.failure, - }), - ); - }), - ), - getViewerAccess: (input) => { const { owner, name } = parseRepositorySelector(input.repository); return graphqlRead({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index f2629f68b956..1882b692f2b0 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -9,6 +9,18 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; +const coreFields = { + checksTruncated: false, + comparison: { behindBy: 0, viewerCanUpdate: true }, + viewerAccess: { + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }, +}; + it.effect("maps credential verification failures without relabeling operation failures", () => Effect.gen(function* () { let verificationFails = true; @@ -229,7 +241,7 @@ describe("gitHubViewerPermissions", () => { }); }); - it.effect("uses the small viewer-access read for core permissions", () => + it.effect("uses the permissions carried by the core read without a second request", () => Effect.gen(function* () { const provider = yield* make; const detail = yield* provider.getChangeRequest({ @@ -276,6 +288,9 @@ describe("gitHubViewerPermissions", () => { Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ getPullRequestDetail: () => Effect.succeed({ + ...coreFields, + comparison: null, + viewerAccess: { ...coreFields.viewerAccess, canWrite: false, canTriage: false }, authorId: null, number: 7, title: "Pull request 7", @@ -285,9 +300,9 @@ describe("gitHubViewerPermissions", () => { headRepositoryOwner: null, headBranch: "feat/page", baseBranch: "main", - state: "open", + state: "open" as const, isDraft: false, - mergeability: "mergeable", + mergeability: "mergeable" as const, reviewDecision: null, additions: 1, deletions: 1, @@ -304,25 +319,22 @@ describe("gitHubViewerPermissions", () => { checks: [], comments: [], commits: [], - }), - getRepositoryAccess: () => - GitHubCli.PinnedGitHubCredential.pipe( - Effect.map((credential) => ({ - canWrite: false, - mergeCapabilities: { - merge: true, - squash: credential?.credentialFingerprint !== "restricted", - rebase: true, - }, - })), + }).pipe( + Effect.flatMap((detail) => + GitHubCli.PinnedGitHubCredential.pipe( + Effect.map((credential) => ({ + ...detail, + viewerAccess: { + ...detail.viewerAccess, + mergeCapabilities: { + ...detail.viewerAccess.mergeCapabilities, + squash: credential?.credentialFingerprint !== "restricted", + }, + }, + })), + ), + ), ), - getViewerAccess: () => - Effect.succeed({ - canWrite: false, - canTriage: false, - canUpdate: true, - didAuthor: false, - }), }), ), ), @@ -364,6 +376,7 @@ describe("gitHubViewerPermissions", () => { Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ getPullRequestDetail: () => Effect.succeed({ + ...coreFields, authorId: null, number: 7, title: "Pull request 7", @@ -412,13 +425,14 @@ describe("gitHubViewerPermissions", () => { ]), getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), - getRepositoryAccess: () => + getViewerAccess: () => Effect.succeed({ canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, mergeCapabilities: { merge: true, squash: true, rebase: true }, }), - getViewerAccess: () => - Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -426,6 +440,7 @@ describe("gitHubViewerPermissions", () => { }); const openDetail = { + ...coreFields, authorId: null, number: 7, title: "Pull request 7", @@ -457,6 +472,39 @@ const openDetail = { commits: [], }; +it.effect( + "uses the core comparison and permissions while preserving workflow approval checks", + () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => + Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), + listWorkflowRunsRequiringApproval: () => + Effect.succeed([{ id: 123, name: "tests", url: "https://example.com/runs/123" }]), + }), + ), + ); + + const detail = yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(detail.behindBy).toBe(2); + expect(detail.workflowApprovalsRequired).toBe(1); + expect(detail.checks).toContainEqual({ + name: "tests", + status: "action-required", + description: "A maintainer must approve this workflow before it can run.", + url: "https://example.com/runs/123", + }); + }), +); + it.effect("does not classify same-repository gates as fork workflow approvals", () => Effect.gen(function* () { const provider = yield* make; @@ -476,13 +524,14 @@ it.effect("does not classify same-repository gates as fork workflow approvals", getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), listWorkflowRunsRequiringApproval: () => Effect.die("same-repository pull requests must not probe fork workflow approvals"), - getRepositoryAccess: () => + getViewerAccess: () => Effect.succeed({ canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, mergeCapabilities: { merge: true, squash: true, rebase: true }, }), - getViewerAccess: () => - Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -523,13 +572,14 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => limit: 1_000, }), ), - getRepositoryAccess: () => + getViewerAccess: () => Effect.succeed({ canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, mergeCapabilities: { merge: true, squash: true, rebase: true }, }), - getViewerAccess: () => - Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -562,13 +612,14 @@ it.effect("propagates workflow discovery rate limits", () => cause: new Error("rate limited"), }), ), - getRepositoryAccess: () => + getViewerAccess: () => Effect.succeed({ canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, mergeCapabilities: { merge: true, squash: true, rebase: true }, }), - getViewerAccess: () => - Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), }), ), ), @@ -598,7 +649,13 @@ describe("getViewerPermissions", () => { getViewerAccess: () => Effect.sync(() => { accessReads++; - return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }; + return { + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; }), }), ), @@ -615,7 +672,13 @@ describe("getViewerPermissions", () => { getPullRequestDetail: () => Effect.succeed(openDetail), getPullRequestBaseComparison: () => comparison, getViewerAccess: () => - Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), }); it.effect("offers update-branch when the comparison grants it", () => @@ -661,7 +724,13 @@ describe("getViewerPermissions", () => { getViewerAccess: (input) => Effect.sync(() => { viewerAllowReserve = input.allowReserve; - return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }; + return { + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; }), }), ), @@ -696,7 +765,13 @@ describe("getViewerPermissions", () => { }), ), getViewerAccess: () => - Effect.succeed({ canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }), + Effect.succeed({ + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }), }), ), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index e73e666f76cb..0b76fdda88fc 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -1,7 +1,4 @@ -import * as Cache from "effect/Cache"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import type { PullRequestActor, PullRequestCapabilities, @@ -11,7 +8,6 @@ import type { } from "@t3tools/contracts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; -import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, @@ -193,35 +189,6 @@ const rendersEmpty = (body: string): boolean => export const make = Effect.gen(function* () { const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const repositoryAccessCache = yield* Cache.makeWith( - (key: string) => { - const [cwd, repository, host] = JSON.parse(key) as [string, string, string]; - return cli.getRepositoryAccess({ cwd, repository, host }); - }, - { - capacity: 128, - timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(10) : Duration.zero), - }, - ); - const getRepositoryAccess = (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - }) => - PinnedGitHubCredential.pipe( - Effect.flatMap((credential) => - Cache.get( - repositoryAccessCache, - JSON.stringify([ - input.cwd, - input.repository, - input.host, - credential?.credentialFingerprint ?? null, - ]), - ), - ), - ); - const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => new PullRequestProviderError({ provider: "github", @@ -340,102 +307,80 @@ export const make = Effect.gen(function* () { getChangeRequestStack: (input) => cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), + getChangeRequestPreview: (input) => + cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), + getChangeRequest: (input) => - Effect.all( - [ - cli.getPullRequestDetail(input).pipe( - Effect.flatMap((pullRequest) => - Effect.all({ - // Only an open pull request can be behind anything worth saying so about, and - // only one whose head repository is known can be compared at all. - comparison: - pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null - ? Effect.succeed(null) - : cli - .getPullRequestBaseComparison({ - ...input, - headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, - }) - .pipe(Effect.orElseSucceed(() => null)), - // GitHub omits a fork workflow that has not been approved from the normal check - // rollup. Read the action-required runs by head revision so "all passed" cannot - // be shown while a whole workflow is still waiting to start. - workflowApprovals: - pullRequest.state !== "open" || pullRequest.isCrossRepository !== true - ? Effect.succeed({ - runs: [] as ReadonlyArray, - unavailable: false, - }) - : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null - ? Effect.succeed({ - runs: [] as ReadonlyArray, - unavailable: true, - }) - : cli - .listWorkflowRunsRequiringApproval({ - ...input, - headSha: pullRequest.headSha, - headBranch: pullRequest.headBranch, - headRepositoryOwner: pullRequest.headRepositoryOwner, - isCrossRepository: true, - }) - .pipe( - Effect.matchEffect({ - onFailure: (error) => - error._tag === "GitHubCliRateLimitError" || - error._tag === "SourceControlRateLimitPausedError" - ? Effect.fail(error) - : Effect.succeed({ - runs: [] as ReadonlyArray, - unavailable: true, - }), - onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), - }), - ), - }).pipe(Effect.map((extra) => ({ pullRequest, ...extra }))), - ), - ), - getRepositoryAccess({ - cwd: input.cwd, - repository: input.repository, - host: input.host, - }), - // A small permissions query replaces the deeply paginated review-thread walk on the - // core path. Writes ask again immediately before mutating, so this is presentation. - cli.getViewerAccess(input), - ], - { concurrency: 3 }, - ).pipe( + cli.getPullRequestDetail(input).pipe( + Effect.flatMap((pullRequest) => { + // Fork workflows awaiting approval are absent from the normal check rollup. + const approvals = + pullRequest.state !== "open" || pullRequest.isCrossRepository !== true + ? Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: false, + }) + : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null + ? Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: true, + }) + : cli + .listWorkflowRunsRequiringApproval({ + ...input, + headSha: pullRequest.headSha, + headBranch: pullRequest.headBranch, + headRepositoryOwner: pullRequest.headRepositoryOwner, + isCrossRepository: true, + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + error._tag === "GitHubCliRateLimitError" || + error._tag === "SourceControlRateLimitPausedError" + ? Effect.fail(error) + : Effect.succeed({ + runs: [] as ReadonlyArray, + unavailable: true, + }), + onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), + }), + ); + return approvals.pipe( + Effect.map((workflowApprovals): ProviderChangeRequestDetail => ({ + ...pullRequest, + author: withAvatar(pullRequest.author, new Map(), input.host), + checks: withWorkflowApprovals( + pullRequest.checks, + workflowApprovals.runs, + workflowApprovals.unavailable, + ), + ...(workflowApprovals.unavailable + ? {} + : { workflowApprovalsRequired: workflowApprovals.runs.length }), + reviewers: pullRequest.reviewRequestLogins.map((login) => ({ + login, + name: null, + avatarUrl: null, + })), + mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, + viewerPermissions: gitHubViewerPermissions({ + ...pullRequest.viewerAccess, + canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, + }), + baseComparison: + pullRequest.comparison === null || pullRequest.comparison.behindBy === null + ? "unknown" + : pullRequest.comparison.behindBy > 0 + ? "behind" + : "up-to-date", + ...(pullRequest.comparison?.behindBy == null + ? {} + : { behindBy: pullRequest.comparison.behindBy }), + })), + ); + }), Effect.mapError(fail("getChangeRequest")), - Effect.map(([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ - ...detail.pullRequest, - author: withAvatar(detail.pullRequest.author, new Map(), input.host), - checks: withWorkflowApprovals( - detail.pullRequest.checks, - detail.workflowApprovals.runs, - detail.workflowApprovals.unavailable, - ), - ...(detail.workflowApprovals.unavailable - ? {} - : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }), - reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ - login, - name: null, - avatarUrl: null, - })), - mergeCapabilities: repository.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions({ - ...viewerAccess, - canUpdateBranch: detail.comparison?.viewerCanUpdate === true, - }), - baseComparison: - detail.comparison === null || detail.comparison.behindBy === null - ? "unknown" - : detail.comparison.behindBy > 0 - ? "behind" - : "up-to-date", - ...(detail.comparison?.behindBy == null ? {} : { behindBy: detail.comparison.behindBy }), - })), ), getChangeRequestActivity: (input) => diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index eb09f81417cc..b4361c125c38 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -31,6 +31,7 @@ import type { PullRequestReviewerKind, PullRequestLabelCandidateList, PullRequestState, + PullRequestPreview, PullRequestUpdateMethod, PullRequestViewerPermissions, SourceControlProviderKind, @@ -400,6 +401,14 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** Hosts without a narrow read use their existing detail response for hover cards. */ + readonly getChangeRequestPreview?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect< + Omit, + PullRequestProviderError + >; + /** * The cheap live fields used by linked threads. Optional because a provider without a narrow * endpoint can fall back to its full detail read at the service boundary. diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 4000d383642d..8e9262a08104 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -116,6 +116,215 @@ function hostedChangeRequest(body: string, additions = 1) { }; } +it.effect("caches narrow previews and invalidates them after refresh or mutation", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getViewer: () => Effect.die("preview must not read the viewer"), + getChangeRequestPreview: () => + Effect.sync(() => { + reads += 1; + return changeRequest(1, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const previews = yield* Effect.all([service.preview(ref), service.preview(ref)], { + concurrency: 2, + }); + assert.deepStrictEqual(previews[0], { + ...ref, + title: "Change request 1", + url: "https://host/pull/1", + author: { login: "octocat", name: null, avatarUrl: null }, + state: "open", + isDraft: false, + createdAt: "2026-07-01T00:00:00Z", + }); + assert.strictEqual(reads, 1); + yield* service.invalidate({ reference: ref }); + yield* service.preview(ref); + assert.strictEqual(reads, 2); + yield* service.runAction({ ...ref, action: "close" }); + yield* service.preview(ref); + assert.strictEqual(reads, 3); + yield* service.refreshAfterTurn(ref.projectId); + yield* service.preview(ref); + assert.strictEqual(reads, 4); + const error = yield* Effect.flip(service.preview({ ...ref, repository: "another/repo" })); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(reads, 4); + }), +); + +it.effect("keeps cached previews available and pauses uncached previews until quota resets", () => + Effect.gen(function* () { + let reads = 0; + const retryAt = Date.parse("2099-08-13T14:00:00Z"); + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestPreview: ({ number }) => + Effect.gen(function* () { + reads++; + if (reads === 2) + return yield* new PullRequestProviderError({ + provider: "github", + operation: "getChangeRequestPreview", + reason: "rate-limited", + detail: "GitHub requests are paused until the rate limit resets.", + retryAt, + }); + return changeRequest(number, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.preview(ref); + const limited = yield* Effect.flip(service.preview({ ...ref, number: 2 })); + assert.instanceOf(limited, PullRequestOperationError); + if (limited._tag !== "PullRequestOperationError") return; + assert.include(limited.detail, "paused"); + assert.strictEqual((yield* service.preview(ref)).number, 1); + for (const number of [2, 3, 4]) { + const paused = yield* Effect.flip(service.preview({ ...ref, number })); + assert.instanceOf(paused, PullRequestOperationError); + if (paused._tag !== "PullRequestOperationError") return; + assert.include(paused.detail, "paused"); + } + assert.strictEqual(reads, 2); + yield* TestClock.setTime(retryAt); + assert.strictEqual((yield* service.preview({ ...ref, number: 2 })).number, 2); + assert.strictEqual(reads, 3); + }), +); + +it.effect("reuses only unexpired detail for previews", () => + Effect.gen(function* () { + let previewReads = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), + getChangeRequestPreview: () => + Effect.sync(() => { + previewReads++; + return { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + title: "Updated title", + state: "closed" as const, + }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service.detail(ref); + assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); + assert.strictEqual(previewReads, 0); + yield* TestClock.adjust("16 seconds"); + const preview = yield* service.preview(ref); + assert.strictEqual(preview.title, "Updated title"); + assert.strictEqual(preview.state, "closed"); + assert.strictEqual(previewReads, 1); + }), +); + +it.effect("does not wait for an in-flight detail read to display a preview", () => + Effect.gen(function* () { + const detailStarted = yield* Deferred.make(); + const releaseDetail = yield* Deferred.make(); + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Deferred.succeed(detailStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseDetail)), + Effect.as(hostedChangeRequest("Description")), + ), + getChangeRequestPreview: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const detail = yield* Effect.forkChild(service.detail(ref)); + yield* Deferred.await(detailStarted); + assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); + yield* Deferred.succeed(releaseDetail, undefined); + yield* Fiber.join(detail); + }), +); + +it.effect("keeps previews warm when another project finishes a turn", () => + Effect.gen(function* () { + const reads: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestPreview: (input) => + Effect.sync(() => { + reads.push(input.repository); + return changeRequest(1, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + const web = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const docs = { projectId: "p2" as ProjectId, repository: "acme/docs", number: 1 }; + yield* service.preview(web); + yield* service.preview(docs); + yield* service.preview({ ...web, host: "github.com" }); + assert.deepStrictEqual(reads, ["acme/web", "acme/docs"]); + yield* service.refreshAfterTurn(web.projectId); + yield* service.preview(web); + yield* service.preview(docs); + assert.deepStrictEqual(reads, ["acme/web", "acme/docs", "acme/web"]); + }), +); + +it.effect("uses full detail for hosts without a narrow preview", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/w", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + getChangeRequest: () => + Effect.sync(() => { + reads += 1; + return hostedChangeRequest("Description"); + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const preview = yield* service.preview(ref); + assert.strictEqual(preview.title, "Change request 1"); + assert.strictEqual(reads, 1); + assert.ok(!("body" in preview)); + }), +); + function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, @@ -1840,6 +2049,13 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after state: "open", }); assert.strictEqual(listed.viewers["code.example:3000"], "bilal"); + const preview = yield* service.preview({ + projectId: "ssh" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }); + assert.strictEqual(preview.number, 42); const detail = yield* service.detail({ projectId: "ssh" as ProjectId, host: "code.example:3000", @@ -4071,7 +4287,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => it.effect("isolates routed caches for two credentials belonging to the same account", () => Effect.gen(function* () { - for (const operation of ["summary", "detail", "diff", "filesViewed"] as const) { + for (const operation of ["summary", "detail", "diff", "preview", "filesViewed"] as const) { let credential = "broad"; let calls = 0; const read = () => @@ -4109,6 +4325,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco read().pipe( Effect.as({ patch: "private patch", truncated: false, nextCursor: null }), ), + getChangeRequestPreview: read, }), ], }); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f1369c21d418..2cc1180bb826 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -32,6 +32,7 @@ import { type PullRequestCommentInput, type PullRequestCommentUpdateInput, type PullRequestDetail, + type PullRequestPreview, type PullRequestDiffFileContentsInput, type PullRequestDiffFileContentsResult, type PullRequestDiffStat, @@ -202,6 +203,9 @@ export class PullRequestService extends Context.Service< readonly subscribeRefreshes: Stream.Stream; readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; + readonly preview: ( + input: PullRequestRef, + ) => Effect.Effect; readonly activity: ( input: PullRequestRef, ) => Effect.Effect; @@ -546,6 +550,9 @@ function withRateLimitBackoff( listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), }), getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), + ...(api.getChangeRequestPreview === undefined + ? {} + : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }), ...(api.getChangeRequestSummary === undefined ? {} : { @@ -1679,6 +1686,38 @@ export const make = Effect.gen(function* () { ), ); + const previewFields = (value: PullRequestPreview): PullRequestPreview => ({ + projectId: value.projectId, + repository: value.repository, + number: value.number, + title: value.title, + url: value.url, + author: value.author, + state: value.state, + isDraft: value.isDraft, + createdAt: value.createdAt, + }); + const previewUncached: PullRequestService["Service"]["preview"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project) => + (project.api.getChangeRequestPreview ?? project.api.getChangeRequest)({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe( + Effect.mapError(toPullRequestError("preview")), + Effect.map((value) => + previewFields({ + ...value, + projectId: project.project.id, + repository: project.repository, + }), + ), + ), + ), + ); + const activityUncached: PullRequestService["Service"]["activity"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => @@ -2885,6 +2924,27 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); + + const previewCache = yield* Cache.makeWith( + (key: string) => { + return previewUncached(refOfCacheKey(key)); + }, + { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), + }, + ); + const preview: PullRequestService["Service"]["preview"] = (input) => { + const key = refCacheKey(input); + return Cache.getSuccess(detailCache, key).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Cache.get(previewCache, key), + onSome: (detail) => Effect.succeed(previewFields(detail)), + }), + ), + ); + }; const activity: PullRequestService["Service"]["activity"] = (input) => { const key = refCacheKey(input); return Cache.get(activityCache, key); @@ -3131,6 +3191,7 @@ export const make = Effect.gen(function* () { refreshAfterTurn, detail: credentialCached(detail), activity: credentialCached(activity), + preview: credentialCached(preview), threadComments, diff: credentialCached(diff), diffFileContents, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 8100a167808e..4591ac0437bf 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -17,7 +17,6 @@ import { decodePullRequestSearchJson, decodePullRequestStacksJson, decodeLabelCandidatesJson, - decodeRepositoryAccessJson, decodeReviewerCandidatesJson, decodeReviewThreadCommentsJson, decodeReviewThreadsJson, @@ -810,31 +809,38 @@ describe("reaction decoding", () => { describe("repository access decoding", () => { const repositoryJson = (viewerPermission?: string | null) => JSON.stringify({ - mergeCommitAllowed: true, - squashMergeAllowed: false, - rebaseMergeAllowed: true, - ...(viewerPermission === undefined ? {} : { viewerPermission }), + data: { + repository: { + pullRequest: null, + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + ...(viewerPermission === undefined ? {} : { viewerPermission }), + }, + }, }); - it("reads the three settings gh reports", () => { + it("reads merge settings with viewer permissions", () => { expect( - expectSuccess(decodeRepositoryAccessJson(repositoryJson("ADMIN"))).mergeCapabilities, + expectSuccess(decodeViewerPermissionsJson(repositoryJson("ADMIN"))).mergeCapabilities, ).toEqual({ merge: true, squash: false, rebase: true }); }); it("fails rather than defaulting open when a setting is missing", () => { - const decoded = decodeRepositoryAccessJson(JSON.stringify({ mergeCommitAllowed: true })); + const decoded = decodeViewerPermissionsJson( + JSON.stringify({ data: { repository: { pullRequest: null, mergeCommitAllowed: true } } }), + ); expect(Result.isSuccess(decoded)).toBe(false); }); it("counts the roles that can push as write, and the ones that cannot as read", () => { for (const permission of ["ADMIN", "MAINTAIN", "WRITE"]) { - expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe( + expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( true, ); } for (const permission of ["TRIAGE", "READ", "NONE"]) { - expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(permission))).canWrite).toBe( + expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(permission))).canWrite).toBe( false, ); } @@ -843,14 +849,23 @@ describe("repository access decoding", () => { it("withholds write where gh names no permission, which is not a standing it gave", () => { // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes // the press, where a missing one still leaves the pull request open on its host. - expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson())).canWrite).toBe(false); - expect(expectSuccess(decodeRepositoryAccessJson(repositoryJson(null))).canWrite).toBe(false); + expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false); + expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false); }); }); describe("viewer permission decoding", () => { const viewerJson = (repository: Record) => - JSON.stringify({ data: { repository } }); + JSON.stringify({ + data: { + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + ...repository, + }, + }, + }); it("reads the repository's role and the pull request's own viewer fields together", () => { expect( @@ -862,7 +877,13 @@ describe("viewer permission decoding", () => { }), ), ), - ).toEqual({ canWrite: false, canTriage: false, canUpdate: true, didAuthor: true }); + ).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); }); it("says no to a passer-by on a repository they can only read", () => { @@ -875,7 +896,13 @@ describe("viewer permission decoding", () => { }), ), ), - ).toEqual({ canWrite: false, canTriage: false, canUpdate: false, didAuthor: false }); + ).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, + canWrite: false, + canTriage: false, + canUpdate: false, + didAuthor: false, + }); }); it("reads silence as permission, but not as authorship", () => { @@ -883,6 +910,7 @@ describe("viewer permission decoding", () => { // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, // and claiming it for someone who did not is how an author's own rules get handed out. expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ + mergeCapabilities: { merge: true, squash: false, rebase: true }, canWrite: false, canTriage: false, canUpdate: true, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 6229c142a159..a011702d685e 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import type { PullRequestStackMembership, PullRequestActor, + PullRequestPreview, PullRequestCheck, PullRequestCheckStatus, PullRequestChecksState, @@ -596,6 +597,62 @@ const RawRepositoryAccessSchema = Schema.Struct({ viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), }); +const RawCoreSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + ...RawRepositoryAccessSchema.fields, + pullRequest: Schema.Struct({ + ...RawDetailSchema.fields, + ...RawViewerFieldsSchema.fields, + viewerCanUpdateBranch: Schema.Boolean, + baseRef: Schema.NullOr( + Schema.Struct({ + compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })), + }), + ), + reviewRequests: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ requestedReviewer: Schema.NullOr(RawReviewRequestSchema) }), + ), + }), + labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }), + commits: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + statusCheckRollup: Schema.NullOr( + Schema.Struct({ + contexts: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + ...RawCheckSchema.fields, + checkSuite: Schema.optional( + Schema.NullOr( + Schema.Struct({ + workflowRun: Schema.NullOr( + Schema.Struct({ + workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })), + }), + ), + }), + ), + ), + }), + ), + pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), + }), + }), + ), + }), + }), + ), + }), + }), + }), + }), +}); +const decodeCore = decodeJsonResult(RawCoreSchema); + const RawPullRequestFileSchema = Schema.Struct({ filename: Schema.String, status: Schema.optional(Schema.NullOr(Schema.String)), @@ -644,6 +701,78 @@ export const PULL_REQUEST_LIST_JSON_FIELDS = "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; + +/** Pull refs let the comparison share the detail read without first resolving a fork branch. */ +export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { + repository(owner: $owner, name: $name) { + mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission + pullRequest(number: $number) { + number title url body state isDraft mergeable reviewDecision + additions deletions changedFiles createdAt updatedAt mergedAt closedAt + headRefName baseRefName headRefOid isCrossRepository + headRepositoryOwner { login } + author { login avatarUrl ... on User { id name } } + autoMergeRequest { mergeMethod } + viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch + baseRef { compare(headRef: $headRef) { behindBy } } + reviewRequests(first: 100) { + nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug name } } } + } + labels(first: 100) { nodes { name color } } + commits(last: 1) { + nodes { commit { statusCheckRollup { contexts(first: 100) { + nodes { + __typename + ... on StatusContext { context state targetUrl createdAt description } + ... on CheckRun { + name status conclusion startedAt completedAt detailsUrl + checkSuite { workflowRun { workflow { name } } } + } + } + pageInfo { hasNextPage } + } } } } + } + } + } +}`; + +export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + number title url state isDraft createdAt + author { login avatarUrl ... on User { name } } + } + } +}`; + +const decodePullRequestPreview = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + number: Schema.Int, + title: Schema.String, + url: Schema.String, + state: Schema.String, + isDraft: Schema.Boolean, + createdAt: Schema.String, + author: Schema.NullOr(RawActorSchema), + }), + }), + }), + }), +); + +export function decodePullRequestPreviewJson( + raw: string, +): Result.Result, DecodeFailure> { + return Result.map(decodePullRequestPreview(raw), ({ data }) => ({ + ...data.repository.pullRequest, + author: toActor(data.repository.pullRequest.author), + state: toState(data.repository.pullRequest), + })); +} + export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ @@ -1034,14 +1163,6 @@ export function buildReviewSubmissionJson(input: { }); } -/** - * `viewerPermission` rides along with the merge settings rather than being asked for on its own: - * `gh repo view --json` serves both out of the same GraphQL repository object, so the viewer's - * standing on the repository costs no request of its own. - */ -export const REPOSITORY_ACCESS_JSON_FIELDS = - "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; - export interface GitHubPullRequestListItem { readonly stack?: PullRequestStackMembership; /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ @@ -1489,7 +1610,6 @@ const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunA const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); const decodeActivity = decodeJsonResult(RawActivitySchema); const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); -const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema); const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); @@ -1689,6 +1809,54 @@ export function decodePullRequestStatsJson( return Result.succeed(stats); } +export interface GitHubPullRequestCore extends GitHubPullRequestDetail { + readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; + readonly comparison: GitHubBaseComparison | null; + readonly checksTruncated: boolean; +} + +export function decodePullRequestCoreJson( + raw: string, +): Result.Result { + const decoded = decodeCore(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const repository = decoded.success.data.repository; + const pr = repository.pullRequest; + const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; + return Result.succeed({ + ...toDetail({ + ...pr, + reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) => + requestedReviewer === null ? [] : [requestedReviewer], + ), + labels: pr.labels.nodes, + statusCheckRollup: + contexts?.nodes.map((check) => ({ + ...check, + workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, + })) ?? [], + }), + viewerAccess: { + canWrite: toCanWrite(repository.viewerPermission), + canTriage: toCanTriage(repository.viewerPermission), + ...toPullRequestViewerFields(pr), + mergeCapabilities: { + merge: repository.mergeCommitAllowed, + squash: repository.squashMergeAllowed, + rebase: repository.rebaseMergeAllowed, + }, + }, + comparison: + pr.state !== "OPEN" || pr.baseRef?.compare == null + ? null + : { + behindBy: pr.baseRef.compare.behindBy, + viewerCanUpdate: pr.viewerCanUpdateBranch, + }, + checksTruncated: contexts?.pageInfo.hasNextPage === true, + }); +} + export function decodePullRequestDetailJson( raw: string, ): Result.Result { @@ -2052,7 +2220,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result< }); } -/** What one `gh repo view` answers: what the repository allows, and where the viewer stands. */ +/** Repository settings returned alongside the pull request viewer permissions. */ export interface GitHubRepositoryAccess { readonly mergeCapabilities: PullRequestMergeCapabilities; readonly canWrite: boolean; @@ -2084,22 +2252,6 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean { return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); } -export function decodeRepositoryAccessJson( - raw: string, -): Result.Result { - const decoded = decodeRepositoryAccess(raw); - return Result.isSuccess(decoded) - ? Result.succeed({ - mergeCapabilities: { - merge: decoded.success.mergeCommitAllowed, - squash: decoded.success.squashMergeAllowed, - rebase: decoded.success.rebaseMergeAllowed, - }, - canWrite: toCanWrite(decoded.success.viewerPermission), - }) - : Result.fail(decoded.failure); -} - /** * Who a review may be asked of, and who it has already been asked of, in one read. * @@ -2428,15 +2580,10 @@ export interface GitHubViewerAccess { readonly canUpdateBranch?: boolean; } -/** - * The viewer's standing, asked on its own. Only the write path needs this: reading a pull request - * already carries the same three fields on calls it was making anyway, and this exists so that a - * merge or a close is decided by what GitHub says now rather than by what the page was told when - * it loaded. - */ +/** Core detail and write checks share one read of permissions and merge settings. */ export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { - viewerPermission + mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission pullRequest(number: $number) { viewerCanUpdate viewerDidAuthor } } }`; @@ -2444,7 +2591,7 @@ export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: S const RawViewerPermissionsSchema = Schema.Struct({ data: Schema.Struct({ repository: Schema.Struct({ - viewerPermission: Schema.optional(Schema.NullOr(Schema.String)), + ...RawRepositoryAccessSchema.fields, /** Null for a number that names no pull request the viewer can see. */ pullRequest: Schema.NullOr(RawViewerFieldsSchema), }), @@ -2455,13 +2602,18 @@ const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema); export function decodeViewerPermissionsJson( raw: string, -): Result.Result { +): Result.Result { const decoded = decodeViewerPermissions(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const repository = decoded.success.data.repository; return Result.succeed({ + mergeCapabilities: { + merge: repository.mergeCommitAllowed, + squash: repository.squashMergeAllowed, + rebase: repository.rebaseMergeAllowed, + }, canWrite: toCanWrite(repository.viewerPermission), canTriage: toCanTriage(repository.viewerPermission), ...toPullRequestViewerFields(repository.pullRequest), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 802a0396d68f..0b9bd1136bf3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2729,6 +2729,12 @@ const makeWsRpcLayer = ( "rpc.aggregate": "pull-requests", }, ), + [WS_METHODS.pullRequestsPreview]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsPreview, + withPullRequestViewer(input, pullRequests.preview(input)), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsActivity]: (input) => observeRpcEffect( WS_METHODS.pullRequestsActivity, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9136900fe0f7..abebe4841cbd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2905,6 +2905,7 @@ const CHAT_MARKDOWN_COMPONENTS = { environmentId, input: { projectId: pullRequestProject.id, + host: pullRequestCandidate.authority ?? pullRequestCandidate.host, repository: sourceControlRepositorySelector(pullRequestProject.repositoryIdentity) ?? pullRequestCandidate.repository, diff --git a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index 36a4c8b15c1c..bbdbd9e16f99 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -42,11 +42,10 @@ export function PullRequestLinkPreview({ }) : null, ); - const readDetail = useAtomQueryRunner(pullRequestEnvironment.detail, { + const readPreview = useAtomQueryRunner(pullRequestEnvironment.preview, { reportFailure: false, reportDefect: false, }); - const trigger = confirmBeforeOpen ? cloneElement(link, { onClick: (event) => { @@ -56,7 +55,7 @@ export function PullRequestLinkPreview({ if (resolvingClick) return; setOpen(false); setResolvingClick(true); - void readDetail({ environmentId: target.environmentId, input: target.input }) + void readPreview(target) .then(async (result) => { if (isAtomCommandInterrupted(result)) return; if (result._tag === "Success" && onOpenPullRequest(result.value.url)) return; @@ -84,36 +83,38 @@ export function PullRequestLinkPreview({ return ( - - {detail === null ? ( -

- {detailQuery.isPending ? "Loading pull request details…" : originalUrl} -

- ) : ( -
-
- {detail.repository} - #{detail.number} - · - {state === null ? null : ( - - - {state.label} - - )} -
-

- {detail.title} + {detail !== null || detailQuery.error !== null ? ( + + {detail === null ? ( +

+ {originalUrl}

-
- - {authorLabel} - · - opened {formatRelativeTimeLabel(detail.createdAt)} + ) : ( +
+
+ {detail.repository} + #{detail.number} + · + {state === null ? null : ( + + + {state.label} + + )} +
+

+ {detail.title} +

+
+ + {authorLabel} + · + opened {formatRelativeTimeLabel(detail.createdAt)} +
-
- )} - + )} + + ) : null} ); } diff --git a/knip.jsonc b/knip.jsonc index 0eae85a29acb..a06e0885a2f8 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -23,6 +23,7 @@ "src/claude-history-worker.ts!", "scripts/cli.ts", "scripts/evaluate-thread-titles.ts", + "scripts/measure-pr-preview.ts", "src/provider/testFixtures/*.mjs", ], // Keep the transitive Effect runtime pinned for standalone npm installs. diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index 64ccb9cdad89..af625bbad56e 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -29,6 +29,7 @@ const reads = new Set([ WS_METHODS.pullRequestsSummary, WS_METHODS.pullRequestsStack, WS_METHODS.pullRequestsDetail, + WS_METHODS.pullRequestsPreview, WS_METHODS.pullRequestsActivity, WS_METHODS.pullRequestsThreadComments, WS_METHODS.pullRequestsDiffFileContents, diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 6fa1141669af..75e6b38f4a2e 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -887,6 +887,84 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () => ), ); +it.effect("keeps hover previews fresh after edits and turns", () => + Effect.scoped( + Effect.gen(function* () { + const refreshEvents = yield* PubSub.unbounded(); + let title = "Original title"; + let reads = 0; + const read = (input: unknown) => + Effect.sync(() => { + expect(input).toEqual(reference); + reads++; + return { ...reference, title }; + }); + const reference = { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 1, + host: "github.example.com", + }; + const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents), + [WS_METHODS.pullRequestsPreview]: read, + [WS_METHODS.pullRequestsDetail]: read, + [WS_METHODS.pullRequestsUpdate]: (input: { title: string }) => + Effect.sync(() => { + title = input.title; + }), + [WS_METHODS.pullRequestsRunAction]: () => + Effect.sync(() => { + title = "Closed"; + }), + [WS_METHODS.pullRequestsInvalidate]: () => Effect.void, + } as unknown as WsRpcProtocolClient; + const { atoms, registry } = yield* makeTestRuntime(client); + const target = { environmentId: TARGET.environmentId, input: reference }; + const preview = atoms.preview(target); + const unmount = registry.mount(preview); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + expect( + (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title, + ).toBe(title); + yield* Effect.promise(() => executeAtomQuery(registry, preview)); + expect(reads).toBe(1); + const edited = yield* Effect.promise(() => + atoms.update.run(registry, { ...target, input: { ...reference, title: "Edited" } }), + ); + expect(AsyncResult.isSuccess(edited)).toBe(true); + expect( + (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title, + ).toBe("Edited"); + yield* Effect.promise(() => + atoms.runAction.run(registry, { ...target, input: { ...reference, action: "close" } }), + ); + expect( + (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title, + ).toBe("Closed"); + title = "Refreshed"; + yield* Effect.promise(() => + atoms.invalidate.run(registry, { + environmentId: target.environmentId, + input: { reference }, + }), + ); + expect( + (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title, + ).toBe("Refreshed"); + const refreshed = Latch.makeUnsafe(); + const stop = registry.subscribe(preview, (result) => { + if (AsyncResult.isSuccess(result) && result.value.title === "After turn") + refreshed.openUnsafe(); + }); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + title = "After turn"; + yield* PubSub.publish(refreshEvents, 1); + yield* refreshed.await; + }), + ), +); + it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index d8c000863ab1..34768ed3c4b5 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -176,6 +176,15 @@ export function createPullRequestEnvironmentAtoms( refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), ); + const preview = writableQueryFamily( + createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:preview", + tag: WS_METHODS.pullRequestsPreview, + execute: (input) => routedRequest(WS_METHODS.pullRequestsPreview, input), + staleTimeMs: 60_000, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), + }), + ); const labelCandidates = writableQueryFamily( createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:label-candidates", @@ -221,6 +230,7 @@ export function createPullRequestEnvironmentAtoms( refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), detail, + preview, activity, threadComments: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:thread-comments", @@ -295,6 +305,7 @@ export function createPullRequestEnvironmentAtoms( label: "environment-data:pull-requests:run-action", tag: WS_METHODS.pullRequestsRunAction, execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input), + onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))), scheduler: commandScheduler, concurrency: serialPerEnvironment, }), @@ -302,6 +313,7 @@ export function createPullRequestEnvironmentAtoms( label: "environment-data:pull-requests:update", tag: WS_METHODS.pullRequestsUpdate, execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input), + onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))), scheduler: commandScheduler, concurrency: serialPerEnvironment, }), @@ -485,6 +497,12 @@ export function createPullRequestEnvironmentAtoms( label: "environment-data:pull-requests:invalidate", tag: WS_METHODS.pullRequestsInvalidate, execute: (input) => routedRequest(WS_METHODS.pullRequestsInvalidate, input), + onSuccess: ({ environmentId, input }, registry) => + Effect.sync(() => { + if (input.reference !== undefined) { + registry.refresh(preview({ environmentId, input: input.reference })); + } + }), scheduler: commandScheduler, concurrency: serialPerEnvironment, }), diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 41c9250f2c63..b146810a8aec 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -703,6 +703,20 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({ }); export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type; +/** The complete hover card, without checks, permissions, or branch comparisons. */ +export const PullRequestPreview = Schema.Struct({ + projectId: ProjectId, + repository: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + author: Schema.NullOr(PullRequestActor), + state: PullRequestState, + isDraft: Schema.Boolean, + createdAt: IsoDateTime, +}); +export type PullRequestPreview = typeof PullRequestPreview.Type; + /** * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar * status check never loads permissions, repository settings, checks, or base comparison data. diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dec42aac1530..41af6d9f1e9e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -111,6 +111,7 @@ import { PullRequestCommentInput, PullRequestCommentUpdateInput, PullRequestDetail, + PullRequestPreview, PullRequestDiffFileContentsInput, PullRequestDiffFileContentsResult, PullRequestFilesViewedResult, @@ -395,6 +396,7 @@ export const WS_METHODS = { pullRequestsStack: "pullRequests.stack", pullRequestsLinkedThreads: "pullRequests.linkedThreads", pullRequestsDetail: "pullRequests.detail", + pullRequestsPreview: "pullRequests.preview", pullRequestsActivity: "pullRequests.activity", pullRequestsThreadComments: "pullRequests.threadComments", pullRequestsDiffFileContents: "pullRequests.diffFileContents", @@ -737,6 +739,12 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { error: PullRequestRpcError, }); +const WsPullRequestsPreviewRpc = Rpc.make(WS_METHODS.pullRequestsPreview, { + payload: PullRequestRef, + success: PullRequestPreview, + error: PullRequestRpcError, +}); + const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { payload: PullRequestRef, success: PullRequestActivity, @@ -1418,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsStackRpc, WsPullRequestsLinkedThreadsRpc, WsPullRequestsDetailRpc, + WsPullRequestsPreviewRpc, WsPullRequestsActivityRpc, WsPullRequestsThreadCommentsRpc, WsPullRequestsDiffFileContentsRpc, From 869347bc26051bba7c3c0274a7dfcae7770d3e3a Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 19 Sep 2026 04:42:28 +1000 Subject: [PATCH 015/111] fix(server): retry transient git failures during checkpoint capture (#11665) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriver.test.ts | 142 ++++++++++++++++++- apps/server/src/vcs/GitVcsDriver.ts | 2 +- apps/server/src/vcs/VcsProcess.test.ts | 169 +++++++++++++++++++++++ apps/server/src/vcs/VcsProcess.ts | 35 ++++- packages/contracts/src/vcs.ts | 4 + 5 files changed, 348 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index b37119f808ba..4df6d0eefc5c 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -14,7 +16,11 @@ import { assert, it } from "@effect/vitest"; import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; +import * as ProcessRunner from "../processRunner.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import type * as VcsDriver from "./VcsDriver.ts"; +import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; import * as VcsProcess from "./VcsProcess.ts"; import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; @@ -26,6 +32,10 @@ const GitContractLayer = Layer.mergeAll(GitVcsDriver.vcsLayer, GitVcsDriver.laye Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); +const GitCaptureContractLayer = Layer.merge( + GitContractLayer, + ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), +); const runGit = (cwd: string, args: ReadonlyArray) => Effect.gen(function* () { @@ -38,6 +48,22 @@ const runGit = (cwd: string, args: ReadonlyArray) => }); }); +const makeCaptureStore = Effect.fn("test.makeCaptureStore")(function* ( + driver: VcsDriver.VcsDriver["Service"], + cwd: string, +) { + const repository = yield* driver.detectRepository(cwd); + if (repository === null) return yield* Effect.die("Expected a test Git repository"); + const handle = { kind: repository.kind, repository, driver }; + return yield* CheckpointStore.make.pipe( + Effect.provideService(VcsDriverRegistry.VcsDriverRegistry, { + get: () => Effect.succeed(driver), + detect: () => Effect.succeed(handle), + resolve: () => Effect.succeed(handle), + }), + ); +}); + type GitContractError = GitCommandError | PlatformError.PlatformError; runVcsDriverContractSuite({ @@ -260,6 +286,112 @@ it.effect("checkpoint recovery refuses excessive candidates before probing", () }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); +it.effect.each([ + { phase: "add", nestedRecovery: false, expireRecovery: false }, + { phase: "update-ref", nestedRecovery: false, expireRecovery: false }, + { phase: "update-ref", nestedRecovery: true, expireRecovery: false }, + { phase: "add", nestedRecovery: true, expireRecovery: false }, + { phase: "add", nestedRecovery: true, expireRecovery: true }, +])( + "checkpoint handles a $phase lock with nestedRecovery=$nestedRecovery, expireRecovery=$expireRecovery", + ({ phase, nestedRecovery, expireRecovery }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveRunner = yield* ProcessRunner.ProcessRunner; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ref-race-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + if (nestedRecovery) yield* git(["init", "empty"]); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); + const refLockPath = path.join(cwd, ".git", `${checkpointRef}.lock`); + const failed = yield* Deferred.make(); + const retryReached = yield* Deferred.make(); + const allowRetry = yield* Deferred.make(); + const clock = yield* Clock.Clock; + const privateIndexes = new Set(); + let racedAttempts = 0; + let discoveries = 0; + let stageError: VcsProcessExitError | undefined; + const captureProcess = yield* VcsProcess.make.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => { + if (input.args.includes("--others")) discoveries += 1; + if (input.env?.GIT_INDEX_FILE) privateIndexes.add(input.env.GIT_INDEX_FILE); + const initialStage = + phase === "add" && + nestedRecovery && + !input.args.some((arg) => arg.startsWith(":(exclude,literal)")); + if (!input.args.includes(phase) || initialStage || ++racedAttempts !== 1) { + return liveRunner.run(input); + } + const lockPath = phase === "add" ? `${input.env!.GIT_INDEX_FILE!}.lock` : refLockPath; + return Effect.gen(function* () { + yield* fs + .makeDirectory(path.dirname(lockPath), { recursive: true }) + .pipe(Effect.orDie); + yield* fs.writeFileString(lockPath, "concurrent ref writer").pipe(Effect.orDie); + return yield* liveRunner.run(input).pipe( + Effect.ensuring(fs.remove(lockPath).pipe(Effect.orDie)), + Effect.tap(() => Deferred.succeed(failed, undefined)), + ); + }); + }, + }), + ); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + captureProcess.run(input).pipe( + Effect.tapError((error) => { + if (input.args.includes("add") && error._tag === "VcsProcessExitError") + stageError = error; + return Effect.void; + }), + ), + }), + ); + const captureStore = yield* makeCaptureStore(captureDriver, cwd); + const fiber = yield* captureStore.captureCheckpoint({ cwd, checkpointRef }).pipe( + Effect.provideService(Clock.Clock, { + ...clock, + sleep: (duration) => + Duration.toMillis(duration) === 75 + ? Deferred.succeed(retryReached, undefined).pipe( + Effect.andThen(Deferred.await(allowRetry)), + ) + : clock.sleep(duration), + }), + Effect.exit, + Effect.forkScoped, + ); + yield* Deferred.await(failed); + yield* Deferred.await(retryReached); + if (expireRecovery) yield* TestClock.adjust("5 seconds"); + else yield* Deferred.succeed(allowRetry, undefined); + const result = yield* Fiber.join(fiber); + if (expireRecovery) { + if (Exit.isSuccess(result)) + return yield* Effect.die("Expected the recovery deadline to expire"); + const error = Cause.findErrorOption(result.cause); + assert.isTrue(error._tag === "Some"); + if (error._tag === "Some") assert.strictEqual(error.value, stageError); + } else assert.isTrue(Exit.isSuccess(result)); + assert.strictEqual(racedAttempts, expireRecovery ? 1 : 2); + assert.strictEqual(discoveries, nestedRecovery ? 1 : 0); + assert.strictEqual(privateIndexes.size, 1); + assert.strictEqual( + yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }), + !expireRecovery, + ); + for (const index of privateIndexes) { + assert.isFalse(yield* fs.exists(index)); + assert.isFalse(yield* fs.exists(`${index}.lock`)); + } + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)), +); + for (const blockedPhase of ["discovery", "probe", "retry"] as const) { it.effect(`checkpoint recovery has one deadline including ${blockedPhase}`, () => Effect.gen(function* () { @@ -276,11 +408,15 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) { let stageError: VcsProcessExitError | undefined; let privateIndex: string | undefined; let interrupted = false; + let stagingAttempts = 0; const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( Effect.provideService(VcsProcess.VcsProcess, { run: (input) => { const staging = input.args.includes("add") && input.args.includes("-A"); - if (staging) privateIndex = input.env?.GIT_INDEX_FILE; + if (staging) { + privateIndex = input.env?.GIT_INDEX_FILE; + stagingAttempts += 1; + } const block = (blockedPhase === "discovery" && input.args.includes("--others")) || (blockedPhase === "probe" && input.cwd !== cwd && input.args.includes("rev-parse")) || @@ -322,7 +458,8 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) { }, }), ); - const fiber = yield* captureDriver.checkpoints + const captureStore = yield* makeCaptureStore(captureDriver, cwd); + const fiber = yield* captureStore .captureCheckpoint({ cwd, checkpointRef }) .pipe(Effect.flip, Effect.forkScoped); if (blockedPhase === "probe") { @@ -333,6 +470,7 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) { yield* TestClock.adjust(blockedPhase === "probe" ? "2 seconds" : "5 seconds"); const error = yield* Fiber.join(fiber); assert.strictEqual(error, stageError); + assert.strictEqual(stagingAttempts, blockedPhase === "retry" ? 2 : 1); assert.isTrue(interrupted); assert.isDefined(privateIndex); assert.isFalse(yield* fs.exists(privateIndex!)); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index a74a88c4b09a..2aaabeed3d7a 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -766,7 +766,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { - const operation = "GitVcsDriver.checkpoints.captureCheckpoint"; + const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; const indexConfig = [ "-c", "core.fsmonitor=false", diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 80dea03b245c..169383d2cbf8 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -4,10 +4,13 @@ import { HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Cause from "effect/Cause"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -19,6 +22,8 @@ import { import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; +const encodeExitError = Schema.encodeEffect(Schema.fromJsonString(VcsProcessExitError)); + const run = (input: VcsProcess.VcsProcessInput) => Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; @@ -50,6 +55,170 @@ const captureProcessResult = ( ); describe("VcsProcess.run", () => { + it.effect.each([ + { stderr: "fatal: Unable to create '/private/repo/index.lock': File exists", retryable: true }, + { + stderr: "fatal: Unable to create '/private/repo/index.lock': Permission denied", + retryable: false, + }, + { stderr: 'error: open("/private/repo/file"): No such file or directory', retryable: true }, + { + stderr: "fatal: unable to stat '/private/repo/file': No such file or directory", + retryable: true, + }, + { stderr: 'error: open("/private/repo/file"): Permission denied', retryable: false }, + { + stderr: "error: '/private/repo/child/' does not have a commit checked out", + retryable: false, + }, + { stderr: "fatal: index file corrupt", retryable: false }, + ])( + "classifies Git exit retryability without retaining stderr: $stderr", + ({ stderr, retryable }) => + Effect.gen(function* () { + const error = yield* captureProcessResult( + Effect.succeed({ + stdout: "", + stderr, + code: ChildProcessSpawner.ExitCode(128), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + ); + assert.instanceOf(error, VcsProcessExitError); + expect(error.retryable === true).toBe(retryable); + const encoded = yield* encodeExitError(error); + expect(encoded).not.toContain("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/private/repo"); + }), + ); + + it.effect.each(["timeout", "spawn"] as const)( + "checkpoint commands do not retry %s failures", + (kind) => + Effect.gen(function* () { + let attempts = 0; + const fields = { command: "git", argumentCount: 2, cwd: "/workspace" }; + const failure = + kind === "timeout" + ? new ProcessRunner.ProcessTimeoutError({ ...fields, timeoutMs: 30_000 }) + : new ProcessRunner.ProcessSpawnError({ ...fields, cause: new Error("spawn failed") }); + const service = yield* VcsProcess.make.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => + Effect.sync(() => { + attempts += 1; + }).pipe(Effect.andThen(Effect.fail(failure))), + }), + ); + const error = yield* service + .run({ + ...baseInput, + operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, + }) + .pipe(Effect.flip); + expect(attempts).toBe(1); + expect(error._tag).toBe( + kind === "timeout" ? "VcsProcessTimeoutError" : "VcsProcessSpawnError", + ); + }), + ); + + it.effect.each([ + { + name: "recovers after two lock failures", + failures: 2, + attempts: 3, + transient: true, + capture: true, + streaming: false, + }, + { + name: "bounds persistent lock failures", + failures: Infinity, + attempts: 3, + transient: true, + capture: true, + streaming: false, + }, + { + name: "does not retry unknown exits", + failures: Infinity, + attempts: 1, + transient: false, + capture: true, + streaming: false, + }, + { + name: "leaves other operations unchanged", + failures: Infinity, + attempts: 1, + transient: true, + capture: false, + streaming: false, + }, + { + name: "does not replay stdout callbacks", + failures: Infinity, + attempts: 1, + transient: true, + capture: true, + streaming: true, + }, + ])( + "checkpoint command retry $name", + ({ failures, attempts: expectedAttempts, transient, capture, streaming }) => + Effect.gen(function* () { + let attempts = 0; + const service = yield* VcsProcess.make.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => + Effect.sync(() => { + attempts += 1; + return { + stdout: "", + stderr: + attempts > failures + ? "" + : transient + ? "fatal: Unable to create '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/repo/index.lock': File exists" + : "fatal: index file corrupt", + code: ChildProcessSpawner.ExitCode(attempts > failures ? 0 : attempts), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }), + ); + const fiber = yield* service + .run({ + ...baseInput, + operation: capture ? VcsProcess.CHECKPOINT_CAPTURE_OPERATION : baseInput.operation, + ...(streaming ? { onStdoutChunk: () => {} } : {}), + }) + .pipe(Effect.exit, Effect.forkScoped); + yield* TestClock.adjust("150 millis"); + const result = yield* Fiber.join(fiber); + expect(attempts).toBe(expectedAttempts); + if (failures === Infinity) { + if (Exit.isSuccess(result)) + return yield* Effect.die("Expected a capture command failure"); + const error = Cause.findErrorOption(result.cause); + if (error._tag === "None" || error.value._tag !== "VcsProcessExitError") { + return yield* Effect.die("Expected a Git exit error"); + } + expect(error.value.exitCode).toBe(expectedAttempts); + } else { + expect(Exit.isSuccess(result)).toBe(true); + } + }), + ); + it.effect("bounds a synthetic burst of GitHub API processes", () => Effect.gen(function* () { const gate = yield* Deferred.make(); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index fcf1cf96025a..25f3a23c368a 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -1,7 +1,9 @@ import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Match from "effect/Match"; +import * as Schedule from "effect/Schedule"; import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -58,6 +60,8 @@ const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const VCS_PROCESS_CONCURRENCY = 8; const GITHUB_PROCESS_CONCURRENCY = 4; +export const CHECKPOINT_CAPTURE_OPERATION = "GitVcsDriver.checkpoints.captureCheckpoint"; + const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); @@ -104,6 +108,11 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "command-failed"; }; +// Classify before discarding stderr; keep paths and process output out of errors. +const isTransientGitExit = (stderr: string) => + /unable to create [^\n]*\.lock['"]?: file exists/i.test(stderr) || + /(?:unable to stat|lstat\(|error: open\()[^\n]+: no such file or directory/i.test(stderr); + export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); @@ -167,6 +176,7 @@ export const make = Effect.gen(function* () { } if (!input.allowNonZeroExit && result.code !== 0) { + const failureKind = classifyNonZeroExit(input.command, result.stderr); return yield* VcsProcessExitError.fromProcessExit( baseError, { @@ -174,7 +184,10 @@ export const make = Effect.gen(function* () { stderr: result.stderr, stderrTruncated: result.stderrTruncated, }, - classifyNonZeroExit(input.command, result.stderr), + failureKind, + input.command === "git" && + failureKind === "command-failed" && + isTransientGitExit(result.stderr), ); } @@ -191,6 +204,26 @@ export const make = Effect.gen(function* () { const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { const bounded = vcsProcesses.withPermits(1)(runUnbounded(input)); + if ( + input.command === "git" && + input.operation === CHECKPOINT_CAPTURE_OPERATION && + input.onStdoutChunk === undefined + ) { + // Retry the failed command, retaining the private index/tree and recovery's outer deadline. + return yield* bounded.pipe( + Effect.tapError((error) => + Effect.logDebug("checkpoint Git command failed", { + operation: input.operation, + errorTag: error._tag, + }), + ), + Effect.retry({ + times: 2, + while: (error) => error._tag === "VcsProcessExitError" && error.retryable === true, + schedule: Schedule.spaced(Duration.millis(75)), + }), + ); + } return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded; }); diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 1f38c7cb718e..dec0e46b6eef 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -119,6 +119,8 @@ export class VcsProcessExitError extends Schema.TaggedError exitCode: Schema.Number, detail: Schema.String, failureKind: Schema.optional(VcsProcessExitFailureKind), + /** Process-boundary hint for a recognized transient failure; absence is not retryable. */ + retryable: Schema.optional(Schema.Boolean), stderrLength: Schema.optional(NonNegativeInt), stderrTruncated: Schema.optional(Schema.Boolean), }, @@ -131,6 +133,7 @@ export class VcsProcessExitError extends Schema.TaggedError context: VcsProcessErrorContext, error: VcsProcessExitFailure, failureKind: VcsProcessExitFailureKind, + retryable?: boolean, ) { const detail = failureKind === "authentication" @@ -150,6 +153,7 @@ export class VcsProcessExitError extends Schema.TaggedError exitCode: error.exitCode, detail, failureKind, + ...(retryable === true ? { retryable: true } : {}), stderrLength: error.stderr.length, stderrTruncated: error.stderrTruncated, }); From 4d36142c022e0dc797034933d01e200818b4083d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 11:46:17 -0700 Subject: [PATCH 016/111] fix(mobile): keep archived threads visible during iOS search (#12420) --- apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 78b3b62a3c1d..6161c203f11d 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -636,7 +636,9 @@ export function ArchivedThreadsScreen(props: { }, [isFiltered, isInitialLoad]); return ( - + // Keep the list inside this native container. Form-sheet resizing otherwise + // treats the flattened background as a header and shrinks the list to zero. + Date: Fri, 18 Sep 2026 12:55:11 -0700 Subject: [PATCH 017/111] perf(mobile): isolate Material You conversion on Android (#12379) --- .../src/lib/materialYouTheme.android.ts | 108 ++++++++++++++++++ apps/mobile/src/lib/materialYouTheme.test.ts | 2 +- apps/mobile/src/lib/materialYouTheme.ts | 104 +---------------- 3 files changed, 112 insertions(+), 102 deletions(-) create mode 100644 apps/mobile/src/lib/materialYouTheme.android.ts diff --git a/apps/mobile/src/lib/materialYouTheme.android.ts b/apps/mobile/src/lib/materialYouTheme.android.ts new file mode 100644 index 000000000000..ce16b23c89ec --- /dev/null +++ b/apps/mobile/src/lib/materialYouTheme.android.ts @@ -0,0 +1,108 @@ +import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; + +import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme"; +import type { MaterialYouPalette } from "./materialYouPalette"; + +function withAlpha(color: string, alpha: number): string { + const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); + if (!match?.[1]) return color; + const alphaByte = Math.round(Math.min(1, Math.max(0, alpha)) * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + return `#${match[1]}${alphaByte}`; +} + +function materialTone(color: string, tone: number): string { + const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); + if (!match?.[1]) return color; + + const source = Hct.fromInt(argbFromHex(match[1])); + return `${hexFromArgb(Hct.from(source.hue, source.chroma, tone).toInt()).toUpperCase()}FF`; +} + +export function materialYouPaletteToMobileThemeVariables( + palette: MaterialYouPalette, + appearance: MobileThemeAppearance, + base: MobileThemeVariables, +): MobileThemeVariables { + const dark = appearance === "dark"; + const userBubble = dark ? materialTone(palette.inversePrimary, 30) : palette.primary; + const userBubbleForeground = dark ? materialTone(palette.inversePrimary, 90) : palette.onPrimary; + + return { + ...base, + "--color-screen": palette.surface, + "--color-sheet": withAlpha(palette.surfaceContainerLow, 0.98), + "--color-sheet-solid": palette.surfaceContainerLow, + "--color-card": palette.surfaceContainer, + "--color-card-alt": palette.surfaceContainerHigh, + "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8), + "--color-thread-canvas": palette.surfaceContainerLow, + "--color-thread-selected": palette.surfaceContainer, + "--color-thread-selected-foreground": palette.onSurface, + "--color-thread-selected-foreground-muted": palette.onSurfaceVariant, + "--color-composer-panel": withAlpha(palette.surfaceContainerLow, dark ? 0.92 : 0.88), + "--color-composer-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.9 : 0.94), + "--color-composer-border": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), + "--color-foreground": palette.onSurface, + "--color-foreground-secondary": palette.onSurfaceVariant, + "--color-foreground-muted": palette.onSurfaceVariant, + "--color-foreground-tertiary": withAlpha(palette.onSurfaceVariant, 0.58), + "--color-border": withAlpha(palette.outlineVariant, dark ? 0.5 : 0.65), + "--color-border-subtle": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), + "--color-separator": withAlpha(palette.outlineVariant, dark ? 0.24 : 0.34), + "--color-subtle": withAlpha(palette.onSurface, 0.05), + "--color-subtle-strong": withAlpha(palette.onSurface, 0.1), + "--color-inline-skill-background": withAlpha(palette.tertiaryContainer, 0.52), + "--color-inline-skill-border": withAlpha(palette.tertiary, 0.38), + "--color-inline-skill-foreground": palette.onTertiaryContainer, + "--color-primary": palette.primary, + "--color-primary-foreground": palette.onPrimary, + "--color-primary-shadow": withAlpha(palette.scrim, dark ? 0.22 : 0.18), + "--color-secondary": palette.secondaryContainer, + "--color-secondary-foreground": palette.onSecondaryContainer, + "--color-secondary-border": withAlpha(palette.outlineVariant, dark ? 0.42 : 0.56), + "--color-switch-active-track": palette.primary, + "--color-switch-active-thumb": palette.onPrimary, + "--color-switch-inactive-track": palette.surfaceContainerHighest, + "--color-switch-inactive-thumb": palette.outline, + "--color-danger": palette.errorContainer, + "--color-danger-border": withAlpha(palette.onErrorContainer, dark ? 0.42 : 0.5), + "--color-danger-foreground": palette.onErrorContainer, + "--color-input": palette.surfaceContainerLowest, + "--color-input-border": withAlpha(palette.outline, dark ? 0.42 : 0.5), + "--color-sidebar-search": palette.surfaceContainerLowest, + "--color-placeholder": withAlpha(palette.onSurface, 0.62), + "--color-icon": palette.onSurface, + "--color-icon-muted": palette.onSurfaceVariant, + "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68), + "--color-header": palette.surfaceContainerHigh, + "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48), + "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72), + "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18), + "--color-status-bar": palette.surfaceContainerLow, + "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), + "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), + "--color-md-link": palette.primary, + "--color-md-blockquote-border": withAlpha(palette.primary, dark ? 0.34 : 0.26), + "--color-md-blockquote-bg": withAlpha(palette.primaryContainer, dark ? 0.14 : 0.2), + "--color-md-code-bg": withAlpha(palette.onSurface, dark ? 0.08 : 0.06), + "--color-md-code-text": palette.onSurface, + "--color-md-user-code-bg": withAlpha(userBubbleForeground, dark ? 0.18 : 0.22), + "--color-md-user-code-text": userBubbleForeground, + "--color-md-user-fence-bg": withAlpha(userBubbleForeground, dark ? 0.14 : 0.18), + "--color-md-user-fence-text": userBubbleForeground, + "--color-md-hr": withAlpha(palette.outlineVariant, dark ? 0.48 : 0.6), + "--color-user-bubble": userBubble, + "--color-user-bubble-foreground": userBubbleForeground, + "--color-user-bubble-foreground-muted": withAlpha(userBubbleForeground, 0.78), + "--color-user-bubble-skill-foreground": userBubbleForeground, + "--color-backdrop": withAlpha(palette.scrim, dark ? 0.48 : 0.22), + "--color-drawer": palette.surface, + "--color-drawer-shadow": withAlpha(palette.scrim, dark ? 0.32 : 0.12), + "--color-dot-separator": withAlpha(palette.onSurface, 0.2), + "--color-wordmark": palette.onSurface, + "--color-chevron": withAlpha(palette.onSurface, 0.2), + }; +} diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts index 16618ea55d48..dc99f9badecd 100644 --- a/apps/mobile/src/lib/materialYouTheme.test.ts +++ b/apps/mobile/src/lib/materialYouTheme.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; import type { MaterialYouPalette } from "./materialYouPalette"; -import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme"; +import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme.android"; const palette: MaterialYouPalette = { primary: "#6750A4FF", diff --git a/apps/mobile/src/lib/materialYouTheme.ts b/apps/mobile/src/lib/materialYouTheme.ts index ce16b23c89ec..64d4338d1140 100644 --- a/apps/mobile/src/lib/materialYouTheme.ts +++ b/apps/mobile/src/lib/materialYouTheme.ts @@ -1,108 +1,10 @@ -import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; - import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme"; import type { MaterialYouPalette } from "./materialYouPalette"; -function withAlpha(color: string, alpha: number): string { - const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); - if (!match?.[1]) return color; - const alphaByte = Math.round(Math.min(1, Math.max(0, alpha)) * 255) - .toString(16) - .padStart(2, "0") - .toUpperCase(); - return `#${match[1]}${alphaByte}`; -} - -function materialTone(color: string, tone: number): string { - const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color); - if (!match?.[1]) return color; - - const source = Hct.fromInt(argbFromHex(match[1])); - return `${hexFromArgb(Hct.from(source.hue, source.chroma, tone).toInt()).toUpperCase()}FF`; -} - export function materialYouPaletteToMobileThemeVariables( - palette: MaterialYouPalette, - appearance: MobileThemeAppearance, + _palette: MaterialYouPalette, + _appearance: MobileThemeAppearance, base: MobileThemeVariables, ): MobileThemeVariables { - const dark = appearance === "dark"; - const userBubble = dark ? materialTone(palette.inversePrimary, 30) : palette.primary; - const userBubbleForeground = dark ? materialTone(palette.inversePrimary, 90) : palette.onPrimary; - - return { - ...base, - "--color-screen": palette.surface, - "--color-sheet": withAlpha(palette.surfaceContainerLow, 0.98), - "--color-sheet-solid": palette.surfaceContainerLow, - "--color-card": palette.surfaceContainer, - "--color-card-alt": palette.surfaceContainerHigh, - "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8), - "--color-thread-canvas": palette.surfaceContainerLow, - "--color-thread-selected": palette.surfaceContainer, - "--color-thread-selected-foreground": palette.onSurface, - "--color-thread-selected-foreground-muted": palette.onSurfaceVariant, - "--color-composer-panel": withAlpha(palette.surfaceContainerLow, dark ? 0.92 : 0.88), - "--color-composer-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.9 : 0.94), - "--color-composer-border": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), - "--color-foreground": palette.onSurface, - "--color-foreground-secondary": palette.onSurfaceVariant, - "--color-foreground-muted": palette.onSurfaceVariant, - "--color-foreground-tertiary": withAlpha(palette.onSurfaceVariant, 0.58), - "--color-border": withAlpha(palette.outlineVariant, dark ? 0.5 : 0.65), - "--color-border-subtle": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46), - "--color-separator": withAlpha(palette.outlineVariant, dark ? 0.24 : 0.34), - "--color-subtle": withAlpha(palette.onSurface, 0.05), - "--color-subtle-strong": withAlpha(palette.onSurface, 0.1), - "--color-inline-skill-background": withAlpha(palette.tertiaryContainer, 0.52), - "--color-inline-skill-border": withAlpha(palette.tertiary, 0.38), - "--color-inline-skill-foreground": palette.onTertiaryContainer, - "--color-primary": palette.primary, - "--color-primary-foreground": palette.onPrimary, - "--color-primary-shadow": withAlpha(palette.scrim, dark ? 0.22 : 0.18), - "--color-secondary": palette.secondaryContainer, - "--color-secondary-foreground": palette.onSecondaryContainer, - "--color-secondary-border": withAlpha(palette.outlineVariant, dark ? 0.42 : 0.56), - "--color-switch-active-track": palette.primary, - "--color-switch-active-thumb": palette.onPrimary, - "--color-switch-inactive-track": palette.surfaceContainerHighest, - "--color-switch-inactive-thumb": palette.outline, - "--color-danger": palette.errorContainer, - "--color-danger-border": withAlpha(palette.onErrorContainer, dark ? 0.42 : 0.5), - "--color-danger-foreground": palette.onErrorContainer, - "--color-input": palette.surfaceContainerLowest, - "--color-input-border": withAlpha(palette.outline, dark ? 0.42 : 0.5), - "--color-sidebar-search": palette.surfaceContainerLowest, - "--color-placeholder": withAlpha(palette.onSurface, 0.62), - "--color-icon": palette.onSurface, - "--color-icon-muted": palette.onSurfaceVariant, - "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68), - "--color-header": palette.surfaceContainerHigh, - "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48), - "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72), - "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18), - "--color-status-bar": palette.surfaceContainerLow, - "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), - "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10), - "--color-md-link": palette.primary, - "--color-md-blockquote-border": withAlpha(palette.primary, dark ? 0.34 : 0.26), - "--color-md-blockquote-bg": withAlpha(palette.primaryContainer, dark ? 0.14 : 0.2), - "--color-md-code-bg": withAlpha(palette.onSurface, dark ? 0.08 : 0.06), - "--color-md-code-text": palette.onSurface, - "--color-md-user-code-bg": withAlpha(userBubbleForeground, dark ? 0.18 : 0.22), - "--color-md-user-code-text": userBubbleForeground, - "--color-md-user-fence-bg": withAlpha(userBubbleForeground, dark ? 0.14 : 0.18), - "--color-md-user-fence-text": userBubbleForeground, - "--color-md-hr": withAlpha(palette.outlineVariant, dark ? 0.48 : 0.6), - "--color-user-bubble": userBubble, - "--color-user-bubble-foreground": userBubbleForeground, - "--color-user-bubble-foreground-muted": withAlpha(userBubbleForeground, 0.78), - "--color-user-bubble-skill-foreground": userBubbleForeground, - "--color-backdrop": withAlpha(palette.scrim, dark ? 0.48 : 0.22), - "--color-drawer": palette.surface, - "--color-drawer-shadow": withAlpha(palette.scrim, dark ? 0.32 : 0.12), - "--color-dot-separator": withAlpha(palette.onSurface, 0.2), - "--color-wordmark": palette.onSurface, - "--color-chevron": withAlpha(palette.onSurface, 0.2), - }; + return base; } From 53830d413474b2a5749c040d4382c96002894b88 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:12 -0700 Subject: [PATCH 018/111] perf(mobile): isolate iOS Live Activity imports (#12380) --- .../agent-awareness/agentLiveActivity.ios.ts | 9 +++++++++ .../agent-awareness/agentLiveActivity.ts | 12 ++++++++++++ .../agent-awareness/remoteRegistration.test.ts | 8 +++----- .../agent-awareness/remoteRegistration.ts | 18 +++++++++++------- 4 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts create mode 100644 apps/mobile/src/features/agent-awareness/agentLiveActivity.ts diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts new file mode 100644 index 000000000000..ab078b29a665 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts @@ -0,0 +1,9 @@ +import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; + +export function getAgentLiveActivities() { + return AgentActivity.getInstances(); +} + +export function startAgentLiveActivity(props: AgentActivityProps) { + return AgentActivity.start(props); +} diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts new file mode 100644 index 000000000000..44ac017984d9 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts @@ -0,0 +1,12 @@ +import type { LiveActivity } from "expo-widgets"; +import type { AgentActivityProps } from "../../widgets/AgentActivity"; + +export function getAgentLiveActivities(): Array> { + return []; +} + +export function startAgentLiveActivity( + _props: AgentActivityProps, +): LiveActivity | null { + return null; +} diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index bd4109c275e6..df8e023710bd 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -101,11 +101,9 @@ vi.mock("expo-widgets", () => ({ addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })), })); -vi.mock("../../widgets/AgentActivity", () => ({ - default: { - getInstances: widgetMocks.getInstances, - start: widgetMocks.start, - }, +vi.mock("./agentLiveActivity", () => ({ + getAgentLiveActivities: widgetMocks.getInstances, + startAgentLiveActivity: widgetMocks.start, })); // The state modules pull the whole connection stack (and native expo modules) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 5b4ca2423249..a6c31594327a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -35,7 +35,8 @@ import { loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; -import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; +import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import { getAgentLiveActivities, startAgentLiveActivity } from "./agentLiveActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { supportsAgentAwarenessPush } from "./capabilities"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; @@ -522,11 +523,11 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { readonly projectTitle: string; }): void { try { - if (AgentActivity.getInstances().length > 0) { + if (getAgentLiveActivities().length > 0) { return; } const nowIso = new Date(Date.now()).toISOString(); - const activity = AgentActivity.start({ + const activity = startAgentLiveActivity({ title: "T3 Code", subtitle: "Agent work in progress", activeCount: 1, @@ -545,6 +546,9 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { }, ], }); + if (!activity) { + return; + } logRegistrationDebug("live activity card armed for local work", { threadTitle: input.threadTitle, }); @@ -842,7 +846,7 @@ function endLocalLiveActivities(context: string): void { return; } try { - for (const activity of AgentActivity.getInstances()) { + for (const activity of getAgentLiveActivities()) { activity.end("immediate").catch((error: unknown) => { logRegistrationError(context, error); }); @@ -1069,7 +1073,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< } let activities = yield* Effect.try({ - try: () => AgentActivity.getInstances(), + try: () => getAgentLiveActivities(), catch: (cause) => new AgentAwarenessOperationError({ operation: "list-active-live-activities", @@ -1118,7 +1122,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< // The snapshot request yields; an arm-on-send may have created the // card in the meantime. Re-check so two cards are never started. const armedMeanwhile = yield* Effect.try({ - try: () => AgentActivity.getInstances(), + try: () => getAgentLiveActivities(), catch: () => [] as ReadonlyArray>, }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); if (armedMeanwhile.length > 0) { @@ -1127,7 +1131,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< const aggregate = snapshot.aggregate; const primed = yield* Effect.try({ try: () => - AgentActivity.start({ + startAgentLiveActivity({ title: aggregate.title, subtitle: aggregate.subtitle, activeCount: aggregate.activeCount, From 7e7cd32463b528838cb10ef6b89ef6ceac1f7a4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:12 -0700 Subject: [PATCH 019/111] refactor(mobile): split home headers by platform (#12381) --- .../src/features/home/HomeHeader.android.tsx | 161 +++++++++++++ apps/mobile/src/features/home/HomeHeader.tsx | 217 ++---------------- .../src/features/home/HomeHeader.types.ts | 26 +++ apps/mobile/src/features/home/HomeScreen.tsx | 2 +- 4 files changed, 203 insertions(+), 203 deletions(-) create mode 100644 apps/mobile/src/features/home/HomeHeader.android.tsx create mode 100644 apps/mobile/src/features/home/HomeHeader.types.ts diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx new file mode 100644 index 000000000000..78e87431b471 --- /dev/null +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -0,0 +1,161 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import { useCallback, useMemo } from "react"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; +import { + hasCustomHomeListOptions, + PROJECT_SORT_OPTIONS, + THREAD_SORT_OPTIONS, +} from "./home-list-options"; +import type { HomeHeaderProps } from "./HomeHeader.types"; + +export type { HomeHeaderEnvironment } from "./HomeHeader.types"; + +function checkedMenuState(checked: boolean) { + return checked ? ("on" as const) : undefined; +} + +export function HomeHeader(props: HomeHeaderProps) { + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); + const hasCustomListOptions = threadListV2Enabled + ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null + : hasCustomHomeListOptions(props); + const menuActions = useMemo( + () => [ + { + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + state: checkedMenuState(props.selectedEnvironmentId === null), + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), + })), + ], + }, + ...(props.projects.length === 0 + ? [] + : ([ + { + id: "project", + title: "Project", + subactions: [ + { + id: "project:all", + title: "All projects", + state: checkedMenuState(props.selectedProjectKey === null), + }, + ...props.projects.map((project) => ({ + id: `project:${project.key}`, + title: project.label, + state: checkedMenuState(props.selectedProjectKey === project.key), + })), + ], + }, + ] satisfies MenuAction[])), + ...(threadListV2Enabled + ? [] + : ([ + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectSortOrder === option.value), + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.threadSortOrder === option.value), + })), + }, + ] satisfies MenuAction[])), + ], + [ + props.environments, + props.projectSortOrder, + props.projects, + props.selectedEnvironmentId, + props.selectedProjectKey, + props.threadSortOrder, + threadListV2Enabled, + ], + ); + const handleMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "environment:all") { + props.onEnvironmentChange(null); + return; + } + + if (id.startsWith("environment:")) { + const environmentId = id.slice("environment:".length); + const environment = props.environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (environment) { + props.onEnvironmentChange(environment.environmentId); + } + return; + } + + if (id === "project:all") { + props.onProjectChange(null); + return; + } + + if (id.startsWith("project:")) { + const projectKey = id.slice("project:".length); + if (props.projects.some((project) => project.key === projectKey)) { + props.onProjectChange(projectKey); + } + return; + } + + const projectSort = PROJECT_SORT_OPTIONS.find( + (option) => id === `project-sort:${option.value}`, + ); + if (projectSort) { + props.onProjectSortOrderChange(projectSort.value); + return; + } + + const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`); + if (threadSort) { + props.onThreadSortOrderChange(threadSort.value); + return; + } + }, + [props], + ); + + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index bf4c28aa5474..2f044862f703 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,11 +1,6 @@ -import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; - import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useMemo, useRef } from "react"; -import { Platform } from "react-native"; +import { useCallback, useRef } from "react"; import type { SearchBarCommands } from "react-native-screens"; - import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; @@ -14,196 +9,17 @@ import { createNativeMailSearchToolbarItem, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; -import type { HomeProjectSortOrder } from "./homeThreadList"; -import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; -import { - buildHomeListFilterMenu, - type HomeListFilterMenuEnvironment, - type HomeListFilterMenuProject, -} from "./home-list-filter-menu"; +import { buildHomeListFilterMenu } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, } from "./home-list-options"; +import type { HomeHeaderProps } from "./HomeHeader.types"; -export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; - -export function HomeHeader(props: { - readonly environments: ReadonlyArray; - readonly projects: ReadonlyArray; - readonly searchQuery: string; - readonly selectedEnvironmentId: EnvironmentId | null; - readonly selectedProjectKey: string | null; - readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; - readonly onSearchQueryChange: (query: string) => void; - readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; - readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - readonly onOpenEnvironments: () => void; - readonly onOpenSettings: () => void; - readonly onStartNewTask: () => void; -}) { - if (Platform.OS === "android") { - return ; - } - - return ; -} - -type HomeHeaderProps = Parameters[0]; - -function checkedMenuState(checked: boolean) { - return checked ? ("on" as const) : undefined; -} +export type { HomeHeaderEnvironment } from "./HomeHeader.types"; -function AndroidHomeHeader(props: HomeHeaderProps) { - // Thread List v2 lays the list out in fixed creation order, so the - // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. - const threadListV2Enabled = useThreadListV2Enabled(); - const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); - const menuActions = useMemo( - () => [ - { - id: "environment", - title: "Environment", - subactions: [ - { - id: "environment:all", - title: "All environments", - state: checkedMenuState(props.selectedEnvironmentId === null), - }, - ...props.environments.map((environment) => ({ - id: `environment:${environment.environmentId}`, - title: environment.label, - state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), - })), - ], - }, - ...(props.projects.length === 0 - ? [] - : ([ - { - id: "project", - title: "Project", - subactions: [ - { - id: "project:all", - title: "All projects", - state: checkedMenuState(props.selectedProjectKey === null), - }, - ...props.projects.map((project) => ({ - id: `project:${project.key}`, - title: project.label, - state: checkedMenuState(props.selectedProjectKey === project.key), - })), - ], - }, - ] satisfies MenuAction[])), - ...(threadListV2Enabled - ? [] - : ([ - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectSortOrder === option.value), - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.threadSortOrder === option.value), - })), - }, - ] satisfies MenuAction[])), - ], - [ - props.environments, - props.projectSortOrder, - props.projects, - props.selectedEnvironmentId, - props.selectedProjectKey, - props.threadSortOrder, - threadListV2Enabled, - ], - ); - const handleMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - const id = event.nativeEvent.event; - if (id === "environment:all") { - props.onEnvironmentChange(null); - return; - } - - if (id.startsWith("environment:")) { - const environmentId = id.slice("environment:".length); - const environment = props.environments.find( - (candidate) => candidate.environmentId === environmentId, - ); - if (environment) { - props.onEnvironmentChange(environment.environmentId); - } - return; - } - - if (id === "project:all") { - props.onProjectChange(null); - return; - } - - if (id.startsWith("project:")) { - const projectKey = id.slice("project:".length); - if (props.projects.some((project) => project.key === projectKey)) { - props.onProjectChange(projectKey); - } - return; - } - - const projectSort = PROJECT_SORT_OPTIONS.find( - (option) => id === `project-sort:${option.value}`, - ); - if (projectSort) { - props.onProjectSortOrderChange(projectSort.value); - return; - } - - const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`); - if (threadSort) { - props.onThreadSortOrderChange(threadSort.value); - return; - } - }, - [props], - ); - - return ( - <> - - - - ); -} - -function IosHomeHeader(props: HomeHeaderProps) { +export function HomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; // Thread List v2 lays the list out in fixed creation order, so the @@ -231,19 +47,16 @@ function IosHomeHeader(props: HomeHeaderProps) { // Static header config (glass, title, fonts) lives in Stack.tsx // (GLASS_HEADER_OPTIONS). Only dynamic values are set here. headerTintColor: iconColor, - unstable_headerRightItems: - Platform.OS === "ios" - ? () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ] - : undefined, + unstable_headerRightItems: () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: props.onOpenSettings, + type: "button", + }), + ], // The keys below are set per-branch (not `undefined`) so a later // reapply cannot clobber options owned by NativeHeaderToolbar. ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED diff --git a/apps/mobile/src/features/home/HomeHeader.types.ts b/apps/mobile/src/features/home/HomeHeader.types.ts new file mode 100644 index 000000000000..0545db092817 --- /dev/null +++ b/apps/mobile/src/features/home/HomeHeader.types.ts @@ -0,0 +1,26 @@ +import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; +import type { HomeProjectSortOrder } from "./homeThreadList"; +import type { + HomeListFilterMenuEnvironment, + HomeListFilterMenuProject, +} from "./home-list-filter-menu"; + +export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; + +export interface HomeHeaderProps { + readonly environments: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly searchQuery: string; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; + readonly projectSortOrder: HomeProjectSortOrder; + readonly threadSortOrder: SidebarThreadSortOrder; + readonly onSearchQueryChange: (query: string) => void; + readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectChange: (projectKey: string | null) => void; + readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; + readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + readonly onOpenEnvironments: () => void; + readonly onOpenSettings: () => void; + readonly onStartNewTask: () => void; +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c0afbfed2089..a01f5fbe606e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -141,7 +141,7 @@ const ESTIMATED_THREAD_ROW_HEIGHT = 72; const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android - * header (AndroidHomeHeader) is rendered in-flow above this screen and + * header is rendered in-flow above this screen and * already consumes the top safe-area inset, so the list only needs breathing * room here. */ From 429d66c13666c4e243065bbd02bee48e1eea2307 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:12 -0700 Subject: [PATCH 020/111] refactor(mobile): split native menus by platform (#12382) --- apps/mobile/src/components/ControlPill.tsx | 172 +----------------- .../components/ControlPillMenu.android.tsx | 43 +++++ .../mobile/src/components/ControlPillMenu.tsx | 115 ++++++++++++ .../src/components/ControlPillMenu.types.ts | 12 ++ 4 files changed, 173 insertions(+), 169 deletions(-) create mode 100644 apps/mobile/src/components/ControlPillMenu.android.tsx create mode 100644 apps/mobile/src/components/ControlPillMenu.tsx create mode 100644 apps/mobile/src/components/ControlPillMenu.types.ts diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index ba75beeeb554..1a1a84c31bc8 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -1,59 +1,12 @@ -import { MenuView } from "@react-native-menu/menu"; -import * as Haptics from "expo-haptics"; -import { - cloneElement, - isValidElement, - type ComponentProps, - type ReactElement, - type ReactNode, - useMemo, - useRef, -} from "react"; -import { - Platform, - Pressable, - View, - type ColorValue, - type PressableProps, - type AccessibilityProps, -} from "react-native"; -import { withUniwind } from "uniwind"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { type ComponentProps, type ReactNode, useRef } from "react"; +import { Platform, Pressable, View } from "react-native"; import { cn } from "../lib/cn"; -import { withMenuActionIconColors } from "../lib/menu-action-colors"; -import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; import { SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { MaterialIconButton } from "./MaterialIconButton"; import { MaterialButton } from "./MaterialButton"; -const ThemedMenuView = withUniwind( - function NativeMenuView({ - iconColor, - destructiveIconColor, - ...props - }: ComponentProps & { - readonly iconColor?: ColorValue; - readonly destructiveIconColor?: ColorValue; - }) { - const actions = useMemo( - () => - withMenuActionIconColors(props.actions, { - icon: iconColor, - destructiveIcon: destructiveIconColor, - }), - [props.actions, iconColor, destructiveIconColor], - ); - return ; - }, - { - iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" }, - destructiveIconColor: { - fromClassName: "destructiveIconColorClassName", - styleProperty: "accentColor", - }, - }, -); +export { ControlPillMenu } from "./ControlPillMenu"; export function ControlPill(props: { readonly icon?: ComponentProps["name"]; @@ -187,122 +140,3 @@ export function ControlPill(props: { ); } - -// iOS renders UIMenu; AndroidAnchoredMenu adapts the same actions to the -// selected Android appearance and keeps editor menus above the keyboard. -export function ControlPillMenu( - props: Omit, "children" | "themeVariant"> & - Pick & { - readonly children: ReactNode; - readonly className?: string; - }, -) { - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; - const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false }); - const pendingPress = useRef<(() => void) | null>(null); - - if (Platform.OS === "android") { - // Long-press menus keep their child interactive: the child element gets - // an injected onLongPress (mirroring the iOS context-menu interaction) - // so its own tap handling still works. - if (props.shouldOpenOnLongPress && isValidElement(props.children)) { - const child = props.children as ReactElement<{ onLongPress?: () => void }>; - return ( - - {(open) => - cloneElement(child, { - onLongPress: () => { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - open(); - }, - }) - } - - ); - } - return ( - - {props.children} - - ); - } - - const { className: _className, ...menuProps } = props; - let children = menuProps.children; - if (props.shouldOpenOnLongPress && isValidElement(children)) { - const child = children as ReactElement>; - children = cloneElement(child, { - onTouchStart: (event) => { - // Reset for a new touch, not onPressIn, which also fires when a - // finger moves out of the row and back during the same gesture. - menuPress.current.isPreparing = false; - menuPress.current.suppressPress = menuPress.current.isOpen; - pendingPress.current = null; - child.props.onTouchStart?.(event); - }, - onPress: (event) => { - // Accessibility clicks have no touch identifier and must not inherit - // cancellation from a previous physical gesture. - const isTouch = typeof event.nativeEvent.identifier === "number"; - if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) { - return; - } - if (isTouch && menuPress.current.isPreparing) { - // A release can arrive between native menu preparation and display. - // Let UIKit's display/cancel callback decide this press's outcome. - event.persist(); - pendingPress.current = () => child.props.onPress?.(event); - return; - } - child.props.onPress?.(event); - }, - }); - menuProps.onMenuInteractionStart = () => { - menuPress.current.isPreparing = true; - props.onMenuInteractionStart?.(); - }; - menuProps.onOpenMenu = () => { - menuPress.current.isPreparing = false; - menuPress.current.isOpen = true; - menuPress.current.suppressPress = true; - pendingPress.current = null; - props.onOpenMenu?.(); - }; - menuProps.onCloseMenu = () => { - menuPress.current.isPreparing = false; - menuPress.current.isOpen = false; - // Keep this gesture cancelled even if dismissal precedes finger-up. - // A separate JS long-press timer would also swallow holds that never - // open the native menu. - const press = pendingPress.current; - pendingPress.current = null; - props.onCloseMenu?.(); - if (!menuPress.current.suppressPress) { - press?.(); - } - }; - } - return ( - - {children} - - ); -} diff --git a/apps/mobile/src/components/ControlPillMenu.android.tsx b/apps/mobile/src/components/ControlPillMenu.android.tsx new file mode 100644 index 000000000000..984086eb8b13 --- /dev/null +++ b/apps/mobile/src/components/ControlPillMenu.android.tsx @@ -0,0 +1,43 @@ +import * as Haptics from "expo-haptics"; +import { cloneElement, isValidElement, type ReactElement } from "react"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import type { ControlPillMenuProps } from "./ControlPillMenu.types"; + +/** Uses the same actions as iOS menus, with Android appearance and editor anchoring. */ +export function ControlPillMenu(props: ControlPillMenuProps) { + // Long-press menus keep their child interactive: the child element gets + // an injected onLongPress (mirroring the iOS context-menu interaction) + // so its own tap handling still works. + if (props.shouldOpenOnLongPress && isValidElement(props.children)) { + const child = props.children as ReactElement<{ onLongPress?: () => void }>; + return ( + + {(open) => + cloneElement(child, { + onLongPress: () => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + open(); + }, + }) + } + + ); + } + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/components/ControlPillMenu.tsx b/apps/mobile/src/components/ControlPillMenu.tsx new file mode 100644 index 000000000000..b2ddb54e4544 --- /dev/null +++ b/apps/mobile/src/components/ControlPillMenu.tsx @@ -0,0 +1,115 @@ +import { MenuView } from "@react-native-menu/menu"; +import { + cloneElement, + isValidElement, + useMemo, + useRef, + type ComponentProps, + type ReactElement, +} from "react"; +import type { ColorValue, PressableProps } from "react-native"; +import { withUniwind } from "uniwind"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { withMenuActionIconColors } from "../lib/menu-action-colors"; +import type { ControlPillMenuProps } from "./ControlPillMenu.types"; + +const ThemedMenuView = withUniwind( + function NativeMenuView({ + iconColor, + destructiveIconColor, + ...props + }: ComponentProps & { + readonly iconColor?: ColorValue; + readonly destructiveIconColor?: ColorValue; + }) { + const actions = useMemo( + () => + withMenuActionIconColors(props.actions, { + icon: iconColor, + destructiveIcon: destructiveIconColor, + }), + [props.actions, iconColor, destructiveIconColor], + ); + return ; + }, + { + iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" }, + destructiveIconColor: { + fromClassName: "destructiveIconColorClassName", + styleProperty: "accentColor", + }, + }, +); + +export function ControlPillMenu(props: ControlPillMenuProps) { + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; + const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false }); + const pendingPress = useRef<(() => void) | null>(null); + + const { className: _className, ...menuProps } = props; + let children = menuProps.children; + if (props.shouldOpenOnLongPress && isValidElement(children)) { + const child = children as ReactElement>; + children = cloneElement(child, { + onTouchStart: (event) => { + // Reset for a new touch, not onPressIn, which also fires when a + // finger moves out of the row and back during the same gesture. + menuPress.current.isPreparing = false; + menuPress.current.suppressPress = menuPress.current.isOpen; + pendingPress.current = null; + child.props.onTouchStart?.(event); + }, + onPress: (event) => { + // Accessibility clicks have no touch identifier and must not inherit + // cancellation from a previous physical gesture. + const isTouch = typeof event.nativeEvent.identifier === "number"; + if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) { + return; + } + if (isTouch && menuPress.current.isPreparing) { + // A release can arrive between native menu preparation and display. + // Let UIKit's display/cancel callback decide this press's outcome. + event.persist(); + pendingPress.current = () => child.props.onPress?.(event); + return; + } + child.props.onPress?.(event); + }, + }); + menuProps.onMenuInteractionStart = () => { + menuPress.current.isPreparing = true; + props.onMenuInteractionStart?.(); + }; + menuProps.onOpenMenu = () => { + menuPress.current.isPreparing = false; + menuPress.current.isOpen = true; + menuPress.current.suppressPress = true; + pendingPress.current = null; + props.onOpenMenu?.(); + }; + menuProps.onCloseMenu = () => { + menuPress.current.isPreparing = false; + menuPress.current.isOpen = false; + // Keep this gesture cancelled even if dismissal precedes finger-up. + // A separate JS long-press timer would also swallow holds that never + // open the native menu. + const press = pendingPress.current; + pendingPress.current = null; + props.onCloseMenu?.(); + if (!menuPress.current.suppressPress) { + press?.(); + } + }; + } + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/components/ControlPillMenu.types.ts b/apps/mobile/src/components/ControlPillMenu.types.ts new file mode 100644 index 000000000000..eb8d58b6669c --- /dev/null +++ b/apps/mobile/src/components/ControlPillMenu.types.ts @@ -0,0 +1,12 @@ +import type { MenuView } from "@react-native-menu/menu"; +import type { ComponentProps, ReactNode } from "react"; +import type { AccessibilityProps } from "react-native"; + +export type ControlPillMenuProps = Omit< + ComponentProps, + "children" | "themeVariant" +> & + Pick & { + readonly children: ReactNode; + readonly className?: string; + }; From dec0732281f5b7e787ad3a84e5cbe9a1607ea4ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:13 -0700 Subject: [PATCH 021/111] refactor(mobile): isolate thread row appearance by platform (#12383) --- .../features/threads/thread-list-v2-items.tsx | 158 ++++-------------- .../thread-list-v2-row-appearance.android.ts | 46 +++++ .../threads/thread-list-v2-row-appearance.ts | 49 ++++++ 3 files changed, 129 insertions(+), 124 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts create mode 100644 apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index a1b16ee21ffd..bd7b481c337f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1,3 +1,11 @@ +import { + THREAD_LIST_V2_MONO_FONT as MONO_FONT, + THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME, + THREAD_LIST_V2_ROW_DIVIDERS, + selectedThreadRowColors, + getThreadListV2NewBranchMenuTitle, + getThreadListV2RowAppearance, +} from "./thread-list-v2-row-appearance"; import { RowPressable } from "../../components/RowPressable"; import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -13,7 +21,7 @@ import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/t import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; -import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; +import { Alert, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { SymbolView } from "../../components/AppSymbol"; @@ -49,12 +57,6 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match"; * hierarchy rather than card fills. */ -const MONO_FONT = Platform.select({ - ios: "Menlo", - android: "monospace", - default: "monospace", -}); - // Status hues follow the system-wide convention set by sidebar v1 and the // Live Activity/widgets (amber approval, indigo input, sky working) so a // thread reads the same color everywhere it surfaces. @@ -477,23 +479,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const pr = useThreadPr(thread); const theme = useUniwindTheme(); - const screenColor = theme["--color-screen"]; - const drawerColor = theme["--color-drawer"]; - const selectedBackgroundColor = - theme[Platform.OS === "android" ? "--color-thread-selected" : "--color-user-bubble"]; const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; - // The provider badge's border blends into the row's own surface, which - // differs by pane and (for the sidebar pane) selection: the sidebar row - // background becomes the selected fill or the drawer surface, while the - // flat "screen" pane rows always sit on the screen background. - const providerIconSurfaceColor = sidebarPane - ? selected - ? selectedBackgroundColor - : Platform.OS === "android" - ? screenColor - : drawerColor - : screenColor; + const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; @@ -788,11 +776,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -811,9 +795,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-xs tabular-nums", selected - ? Platform.OS === "android" - ? "text-thread-selected-foreground" - : "text-user-bubble-foreground" + ? selectedThreadRowColors.foregroundClassName : (statusLabel?.className ?? "text-foreground-tertiary"), )} > @@ -823,11 +805,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -848,9 +826,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "flex-1 text-xs", selected - ? Platform.OS === "android" - ? "text-thread-selected-foreground-muted" - : "text-user-bubble-foreground-muted" + ? selectedThreadRowColors.mutedForegroundClassName : "text-danger-foreground", )} numberOfLines={1} @@ -869,9 +845,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "shrink text-xs", selected - ? Platform.OS === "android" - ? "text-thread-selected-foreground-muted" - : "text-user-bubble-foreground-muted" + ? selectedThreadRowColors.mutedForegroundClassName : "text-foreground-muted", )} numberOfLines={1} @@ -881,9 +855,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-xs", selected - ? Platform.OS === "android" - ? "text-thread-selected-foreground-muted" - : "text-user-bubble-foreground-muted" + ? selectedThreadRowColors.mutedForegroundClassName : "text-foreground-muted", )} style={{ fontFamily: MONO_FONT }} @@ -897,9 +869,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-xs", selected - ? Platform.OS === "android" - ? "text-thread-selected-foreground-muted" - : "text-user-bubble-foreground-muted" + ? selectedThreadRowColors.mutedForegroundClassName : "text-foreground-tertiary", )} > @@ -913,9 +883,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { size={11} tintColorClassName={ selected - ? Platform.OS === "android" - ? "accent-thread-selected-foreground-muted" - : "accent-user-bubble-foreground-muted" + ? selectedThreadRowColors.mutedIconTintClassName : "accent-foreground-tertiary" } /> @@ -932,9 +900,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { size={12} tintColorClassName={ selected - ? Platform.OS === "android" - ? "accent-thread-selected-foreground" - : "accent-user-bubble-foreground" + ? selectedThreadRowColors.iconTintClassName : pr.state === null || pr.isDraft ? "accent-foreground-muted" : pr.state === "open" @@ -949,11 +915,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityLabel={pr.accessibilityLabel} className={cn( "text-xs", - selected - ? Platform.OS === "android" - ? "text-thread-selected-foreground" - : "text-user-bubble-foreground" - : pr.textClassName, + selected ? selectedThreadRowColors.foregroundClassName : pr.textClassName, )} style={{ fontFamily: MONO_FONT }} > @@ -968,7 +930,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { displayName={props.providerInstance.displayName} accentColor={props.providerInstance.accentColor} showBadge={props.providerInstance.showBadge} - surfaceColor={providerIconSurfaceColor} + surfaceColor={rowAppearance.providerIconSurfaceColor} /> ) : null} @@ -979,14 +941,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant === "card" ? ( {sidebarPane ? ( cardContent @@ -1019,10 +963,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { separates rows. The opaque screen background stays so swipe actions reveal behind the row. */ - - {cardContent} - - {Platform.OS !== "android" && props.showTrailingDivider !== false ? ( + {cardContent} + {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( ) : null} @@ -1031,36 +973,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : ( { close(); onSelectThread(thread); }} - style={ - sidebarPane || Platform.OS === "android" - ? { - backgroundColor: selected - ? selectedBackgroundColor - : sidebarPane && Platform.OS !== "android" - ? drawerColor - : screenColor, - borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS, - } - : undefined - } + style={rowAppearance.style} > {/* Settled history recedes: dimmed favicon + muted title. */} @@ -1107,9 +1028,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { className={cn( "text-sm tabular-nums", selected - ? Platform.OS === "android" - ? "text-thread-selected-foreground-muted" - : "text-user-bubble-foreground-muted" + ? selectedThreadRowColors.mutedForegroundClassName : snoozedRow ? "text-foreground-secondary" : "text-foreground-tertiary", @@ -1131,15 +1050,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} Date: Fri, 18 Sep 2026 12:55:14 -0700 Subject: [PATCH 022/111] refactor(mobile): split settings selection rows by platform (#12384) --- .../threads/ThreadSettingsRows.android.tsx | 29 ++++ .../threads/ThreadSettingsRows.shared.tsx | 140 ++++++++++++++++++ .../features/threads/ThreadSettingsRows.tsx | 38 +++++ .../features/threads/ThreadSettingsSheet.tsx | 137 +---------------- 4 files changed, 208 insertions(+), 136 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadSettingsRows.android.tsx create mode 100644 apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx create mode 100644 apps/mobile/src/features/threads/ThreadSettingsRows.tsx diff --git a/apps/mobile/src/features/threads/ThreadSettingsRows.android.tsx b/apps/mobile/src/features/threads/ThreadSettingsRows.android.tsx new file mode 100644 index 000000000000..98b414ef55d3 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadSettingsRows.android.tsx @@ -0,0 +1,29 @@ +import { MaterialRadioIndicator } from "../../components/MaterialRadioIndicator"; +import { + ModelRowContent, + ChoiceRowContent, + type ModelRowProps, + type ChoiceRowProps, +} from "./ThreadSettingsRows.shared"; + +export function ModelRow(props: ModelRowProps) { + return ( + } + /> + ); +} + +export function ChoiceRow(props: ChoiceRowProps) { + return ( + } + /> + ); +} diff --git a/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx b/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx new file mode 100644 index 000000000000..70a66704cdd3 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx @@ -0,0 +1,140 @@ +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { cn } from "../../lib/cn"; +import type { ModelOption } from "../../lib/modelOptions"; + +export type ModelRowProps = { + readonly option: ModelOption; + readonly selected: boolean; + readonly onPress: () => void; + readonly isFavorite: boolean; + readonly favoritesLoaded: boolean; + readonly onToggleFavorite: () => void; + readonly isFirst: boolean; + readonly isLast: boolean; +}; + +export type ChoiceRowProps = { + readonly label: string; + readonly description?: string; + readonly selected: boolean; + readonly onPress: () => void; + readonly isLast: boolean; +}; + +type RowSelectionProps = { + readonly leadingSelection?: ReactNode; + readonly trailingSelection?: ReactNode; + readonly minimumHeight?: number; +}; + +export function ModelRowContent( + props: ModelRowProps & + RowSelectionProps & { + readonly labelNumberOfLines: number; + readonly selectedClassName?: string; + }, +) { + return ( + + + {props.leadingSelection} + + + + {props.option.label} + + {props.option.isDefault ? ( + + Default + + ) : null} + {props.option.isLegacy ? ( + + Legacy + + ) : null} + {props.option.isUnavailable ? ( + Unavailable + ) : null} + + {props.option.subtitle ? ( + + {props.option.subtitle} + + ) : null} + + {props.trailingSelection} + + + + + + ); +} + +/** Single option inside a submenu panel. */ +export function ChoiceRowContent(props: ChoiceRowProps & RowSelectionProps) { + return ( + + {props.leadingSelection} + + {props.label} + {props.description ? ( + {props.description} + ) : null} + + {props.trailingSelection} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadSettingsRows.tsx b/apps/mobile/src/features/threads/ThreadSettingsRows.tsx new file mode 100644 index 000000000000..016be4cb9c16 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadSettingsRows.tsx @@ -0,0 +1,38 @@ +import { SymbolView } from "../../components/AppSymbol"; +import { + ModelRowContent, + ChoiceRowContent, + type ModelRowProps, + type ChoiceRowProps, +} from "./ThreadSettingsRows.shared"; + +function SelectedCheckmark(props: { readonly selected: boolean }) { + return props.selected ? ( + + ) : null; +} + +export function ModelRow(props: ModelRowProps) { + return ( + } + /> + ); +} + +export function ChoiceRow(props: ChoiceRowProps) { + return ( + } + /> + ); +} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 6c0218d453f5..9dbefe6018b7 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -40,7 +40,6 @@ import { AppText as Text } from "../../components/AppText"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { MaterialButton } from "../../components/MaterialButton"; import { MaterialIconButton } from "../../components/MaterialIconButton"; -import { MaterialRadioIndicator } from "../../components/MaterialRadioIndicator"; import { ProviderIcon } from "../../components/ProviderIcon"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; @@ -69,6 +68,7 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET, NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; +import { ModelRow, ChoiceRow } from "./ThreadSettingsRows"; import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; import { canCommitPendingModel, @@ -113,101 +113,6 @@ const EMPTY_MODEL_FAVORITES: ReadonlyArray<{ readonly model: string; }> = []; const FAVORITES_PROVIDER_FILTER = "@favorites"; -function ModelRow(props: { - readonly option: ModelOption; - readonly selected: boolean; - readonly onPress: () => void; - readonly isFavorite: boolean; - readonly favoritesLoaded: boolean; - readonly onToggleFavorite: () => void; - readonly isFirst: boolean; - readonly isLast: boolean; -}) { - const selectedMaterialRow = Platform.OS === "android" && props.selected; - return ( - - - {Platform.OS === "android" ? : null} - - - - {props.option.label} - - {props.option.isDefault ? ( - - Default - - ) : null} - {props.option.isLegacy ? ( - - Legacy - - ) : null} - {props.option.isUnavailable ? ( - Unavailable - ) : null} - - {props.option.subtitle ? ( - - {props.option.subtitle} - - ) : null} - - {props.selected && Platform.OS !== "android" ? ( - - ) : null} - - - - - - ); -} - /** Provider catalog header with its harness logo and disclosure state. */ function ProviderHeader(props: { readonly driver: string | undefined; @@ -296,46 +201,6 @@ function DisclosureRow(props: { ); } -/** Single option inside a submenu panel. */ -function ChoiceRow(props: { - readonly label: string; - readonly description?: string; - readonly selected: boolean; - readonly onPress: () => void; - readonly isLast: boolean; -}) { - return ( - - {Platform.OS === "android" ? : null} - - {props.label} - {props.description ? ( - {props.description} - ) : null} - - {props.selected && Platform.OS !== "android" ? ( - - ) : null} - - ); -} - function SwitchRow(props: { readonly label: string; readonly value: boolean; From 4fb9dfc8b8600d830b36da1c15c5f0cd21627e77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:14 -0700 Subject: [PATCH 023/111] refactor(mobile): centralize platform header rendering (#12388) --- .../src/components/ScreenHeader.android.tsx | 203 ++++++++++++ apps/mobile/src/components/ScreenHeader.tsx | 164 ++++++++++ .../src/components/ScreenHeader.types.ts | 76 +++++ .../components/ScreenHeaderButton.android.tsx | 1 + .../src/components/ScreenHeaderButton.tsx | 4 + .../src/components/headerMenu.android.ts | 43 +++ apps/mobile/src/components/headerMenu.test.ts | 66 ++++ .../src/components/nativeHeaderMenu.ios.tsx | 44 +++ .../connection/ConnectionsNewRouteScreen.tsx | 24 +- .../SettingsEnvironmentsRouteScreen.tsx | 18 +- .../settings/components/SettingsScreen.tsx | 30 +- .../threads/ThreadGitControls.android.tsx | 30 ++ .../features/threads/ThreadGitControls.tsx | 95 +++--- .../native/NativeHeaderToolbar.android.tsx | 43 +++ .../mobile/src/native/NativeHeaderToolbar.tsx | 294 ++++++++++++++++++ apps/mobile/src/native/StackHeader.tsx | 289 +---------------- 16 files changed, 1042 insertions(+), 382 deletions(-) create mode 100644 apps/mobile/src/components/ScreenHeader.android.tsx create mode 100644 apps/mobile/src/components/ScreenHeader.tsx create mode 100644 apps/mobile/src/components/ScreenHeader.types.ts create mode 100644 apps/mobile/src/components/ScreenHeaderButton.android.tsx create mode 100644 apps/mobile/src/components/ScreenHeaderButton.tsx create mode 100644 apps/mobile/src/components/headerMenu.android.ts create mode 100644 apps/mobile/src/components/headerMenu.test.ts create mode 100644 apps/mobile/src/components/nativeHeaderMenu.ios.tsx create mode 100644 apps/mobile/src/features/threads/ThreadGitControls.android.tsx create mode 100644 apps/mobile/src/native/NativeHeaderToolbar.android.tsx create mode 100644 apps/mobile/src/native/NativeHeaderToolbar.tsx diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx new file mode 100644 index 000000000000..edb0b770e93c --- /dev/null +++ b/apps/mobile/src/components/ScreenHeader.android.tsx @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { NativeStackScreenOptions } from "../native/StackHeader"; +import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { AndroidScreenHeader } from "./AndroidScreenHeader"; +import { ScreenHeaderButton } from "./ScreenHeaderButton.android"; +import { SymbolView } from "./AppSymbol"; +import { ControlPillMenu } from "./ControlPill"; +import { MaterialSearchField } from "./MaterialSearchField"; +import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; +import type { ScreenHeaderProps } from "./ScreenHeader.types"; + +export function ScreenHeader(props: ScreenHeaderProps) { + const { search } = props; + const insets = useSafeAreaInsets(); + const { themeVariables } = useAppearancePreferences(); + const inputRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const searching = search !== undefined && (searchOpen || search.value.length > 0); + const onSearchChange = search?.onChangeText; + const closeSearch = useCallback(() => { + onSearchChange?.(""); + setSearchOpen(false); + Keyboard.dismiss(); + }, [onSearchChange]); + useEffect(() => { + if (!searching || search?.mode === "inline") return; + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + closeSearch(); + return true; + }); + return () => subscription.remove(); + }, [closeSearch, searching, search?.mode]); + const menuView = props.menus?.map((menu) => ( + { + const action = findHeaderMenuAction(menu.items, nativeEvent.event); + if (action && !action.disabled) action.onPress(); + }} + > + {search?.mode === "inline" ? ( + + + + ) : ( + + )} + + )); + const options = ( + + ); + if (search?.mode === "inline") { + return ( + <> + {options} + + + {props.onBack ? ( + + + + ) : null} + + + + + {menuView} + + + + ); + } + const header = ( + : undefined} + hideBottomBorder={props.hideBottomBorder} + actions={ + search + ? [ + { + accessibilityLabel: search.placeholder, + icon: "magnifyingglass", + onPress: () => setSearchOpen(true), + }, + ...(search.onRefresh + ? [ + { + accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh", + icon: "arrow.clockwise" as const, + onPress: search.onRefresh, + }, + ] + : []), + ...(props.actions ?? []), + ] + : props.actions + } + trailing={ + <> + {menuView} + {props.trailing} + + } + /> + ); + return ( + <> + {options} + {search ? ( + + + {header} + + {searching ? ( + + + + + + + ) : null} + + ) : ( + header + )} + + ); +} diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx new file mode 100644 index 000000000000..b5e5d1a5dd12 --- /dev/null +++ b/apps/mobile/src/components/ScreenHeader.tsx @@ -0,0 +1,164 @@ +import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; +import { useId } from "react"; +import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; +import { ScreenHeaderButton } from "./ScreenHeaderButton"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader"; +import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../features/layout/native-mail-search-toolbar"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types"; +import type { AppSymbolName } from "./AppSymbol"; + +function iosIcon(icon: AppSymbolName) { + return typeof icon === "string" ? icon : icon.ios; +} + +type MailMenu = NonNullable; +function mailMenuItems(items: ReadonlyArray): MailMenu["items"] { + return items.map((item) => + "items" in item + ? { type: "submenu", title: item.title ?? "", items: mailMenuItems(item.items) } + : { + type: "action", + title: item.title, + state: item.selected ? "on" : "off", + disabled: item.disabled, + onPress: item.onPress, + }, + ); +} + +export function ScreenHeader(props: ScreenHeaderProps) { + const headerId = useId(); + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const { themeVariables } = useAppearancePreferences(); + const { search, menus } = props; + const menu = menus?.[0]; + const compactSearch = + search !== undefined && + (search.compactToolbar ?? !layout.usesSplitView) && + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); + const refresh = search?.refreshInToolbar ? search.onRefresh : undefined; + return ( + <> + [ + createNativeMailSearchToolbarItem({ + showsSearchDismissButton: true, + placeholder: search.compactPlaceholder ?? search.placeholder, + onSearchTextChange: search.onChangeText, + searchTextChangeId: `${headerId}-search-text`, + ...(refresh + ? { + composeButtonId: `${headerId}-refresh`, + composeSystemImageName: "arrow.clockwise", + onComposePress: refresh, + } + : undefined), + ...(menu + ? { + filterButtonId: `${headerId}-filter`, + filterSystemImageName: iosIcon(menu.icon), + filterMenu: { title: menu.title, items: mailMenuItems(menu.items) }, + } + : undefined), + }), + ] + : undefined, + headerSearchBarOptions: compactSearch + ? undefined + : { + allowToolbarIntegration: true, + ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline" + ? { placement: "integratedButton" as const } + : undefined), + autoCapitalize: "none", + hideNavigationBar: false, + ...(search.mode === "inline" ? { obscureBackground: false } : undefined), + placeholder: search.placeholder, + onChangeText: (event) => search.onChangeText(event.nativeEvent.text), + onCancelButtonPress: () => search.onChangeText(""), + }, + } + : undefined), + ...props.options, + }} + /> + {props.sidebar !== false && layout.usesSplitView ? ( + + {props.backInSplitView && (props.backInSplitView.onPress || props.onBack) ? ( + + ) : null} + + + ) : null} + {(props.actions?.length || + (!compactSearch && refresh) || + visibleMenus.length || + props.trailing) && + props.options?.unstable_headerRightItems === undefined ? ( + + {props.actions?.map((action) => ( + + ))} + {refresh && !compactSearch ? ( + + ) : null} + {visibleMenus.map(createNativeHeaderMenu)} + {props.trailing} + + ) : null} + + ); +} + +export type { + ScreenHeaderAction, + ScreenHeaderMenu, + ScreenHeaderMenuItem, + ScreenHeaderProps, + ScreenHeaderSearch, +} from "./ScreenHeader.types"; diff --git a/apps/mobile/src/components/ScreenHeader.types.ts b/apps/mobile/src/components/ScreenHeader.types.ts new file mode 100644 index 000000000000..3373181a3566 --- /dev/null +++ b/apps/mobile/src/components/ScreenHeader.types.ts @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import type { ColorValue } from "react-native"; +import type { AppNativeStackNavigationOptions } from "../native/StackHeader"; +import type { AppSymbolName } from "./AppSymbol"; + +export interface ScreenHeaderAction { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly selected?: boolean; + readonly tintColor?: ColorValue; +} + +export type ScreenHeaderMenuItem = + | { + readonly id: string; + readonly title: string; + readonly icon?: string; + readonly subtitle?: string; + readonly disabled?: boolean; + readonly selected?: boolean; + readonly onPress: () => void; + } + | { + readonly id: string; + readonly title?: string; + readonly icon?: string; + readonly inline?: boolean; + readonly items: ReadonlyArray; + }; + +export interface ScreenHeaderMenu { + readonly title: string; + readonly icon: AppSymbolName; + readonly status?: string; + readonly separateBackground?: boolean; + readonly items: ReadonlyArray; +} + +export interface ScreenHeaderSearch { + readonly value: string; + readonly onChangeText: (value: string) => void; + readonly placeholder: string; + readonly mode?: "inline" | "collapsible"; + readonly compactToolbar?: boolean; + readonly compactPlaceholder?: string; + readonly closeAccessibilityLabel?: string; + readonly clearAccessibilityLabel?: string; + readonly onRefresh?: () => void; + readonly refreshAccessibilityLabel?: string; + readonly refreshInToolbar?: boolean; +} + +export interface ScreenHeaderProps { + readonly title: string; + readonly subtitle?: string; + readonly actions?: ReadonlyArray; + readonly menus?: ReadonlyArray; + readonly search?: ScreenHeaderSearch; + readonly onBack?: () => void; + readonly sidebar?: boolean; + readonly backInSplitView?: { + readonly accessibilityLabel: string; + readonly icon: string; + readonly separateBackground?: boolean; + readonly onPress?: () => void; + }; + readonly hideBottomBorder?: boolean; + readonly trailing?: ReactNode; + /** Search scenes use sheet colors on iOS and header colors on Android. */ + readonly matchSearchSurface?: boolean; + /** Custom native layouts can override options without duplicating the header renderer. */ + readonly options?: AppNativeStackNavigationOptions; + readonly optionsVersion?: unknown; +} diff --git a/apps/mobile/src/components/ScreenHeaderButton.android.tsx b/apps/mobile/src/components/ScreenHeaderButton.android.tsx new file mode 100644 index 000000000000..905997a3da6e --- /dev/null +++ b/apps/mobile/src/components/ScreenHeaderButton.android.tsx @@ -0,0 +1 @@ +export { AndroidHeaderIconButton as ScreenHeaderButton } from "./AndroidScreenHeader"; diff --git a/apps/mobile/src/components/ScreenHeaderButton.tsx b/apps/mobile/src/components/ScreenHeaderButton.tsx new file mode 100644 index 000000000000..929a2e9f62e2 --- /dev/null +++ b/apps/mobile/src/components/ScreenHeaderButton.tsx @@ -0,0 +1,4 @@ +import { NativeHeaderToolbar } from "../native/StackHeader"; +export const ScreenHeaderButton: ( + props: Parameters[0] & { readonly selected?: boolean }, +) => ReturnType = NativeHeaderToolbar.Button; diff --git a/apps/mobile/src/components/headerMenu.android.ts b/apps/mobile/src/components/headerMenu.android.ts new file mode 100644 index 000000000000..49c9d9abeb6a --- /dev/null +++ b/apps/mobile/src/components/headerMenu.android.ts @@ -0,0 +1,43 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { ScreenHeaderMenuItem } from "./ScreenHeader.types"; + +export function androidHeaderMenuActions(items: ReadonlyArray): MenuAction[] { + return items.flatMap((item): MenuAction[] => + "items" in item + ? item.inline && !item.title + ? androidHeaderMenuActions(item.items) + : [ + { + id: item.id, + title: item.title ?? "", + image: item.icon, + subactions: androidHeaderMenuActions(item.items), + }, + ] + : [ + { + id: item.id, + title: item.title, + subtitle: item.subtitle, + image: item.icon, + state: item.selected ? "on" : undefined, + attributes: item.disabled ? { disabled: true } : undefined, + }, + ], + ); +} + +export function findHeaderMenuAction( + items: ReadonlyArray, + id: string, +): Extract void }> | undefined { + for (const item of items) { + if ("items" in item) { + const action = findHeaderMenuAction(item.items, id); + if (action) return action; + } else if (item.id === id) { + return item; + } + } + return undefined; +} diff --git a/apps/mobile/src/components/headerMenu.test.ts b/apps/mobile/src/components/headerMenu.test.ts new file mode 100644 index 000000000000..bbc0b1d1ee4d --- /dev/null +++ b/apps/mobile/src/components/headerMenu.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vite-plus/test"; +import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; +import type { ScreenHeaderMenuItem } from "./ScreenHeader.types"; + +const items: ReadonlyArray = [ + { + id: "sessions", + title: "Sessions", + items: [ + { + id: "terminal-session:main", + title: "Main shell", + subtitle: "Running · workspace", + selected: true, + onPress: () => {}, + }, + { id: "closed", title: "Closed shell", disabled: true, onPress: () => {} }, + ], + }, +]; + +describe("header menus", () => { + it("keeps named sections as Android submenus", () => { + const actions = androidHeaderMenuActions([ + { + id: "text-size", + title: "Text size", + inline: true, + items: [{ id: "font-increase", title: "A+", onPress: () => {} }], + }, + ]); + expect(actions[0]?.title).toBe("Text size"); + expect(actions[0]?.subactions?.[0]?.id).toBe("font-increase"); + }); + + it("flattens inline groups while keeping their action IDs", () => { + const actions = androidHeaderMenuActions([ + { id: "modes", inline: true, items: [{ id: "code", title: "Code", onPress: () => {} }] }, + ]); + expect(actions.map(({ id }) => id)).toEqual(["code"]); + }); + + it("keeps stable action IDs, selection, and disabled state inside submenus", () => { + const menu = androidHeaderMenuActions(items); + expect(menu[0]?.id).toBe("sessions"); + expect(menu[0]?.subactions?.[0]).toMatchObject({ + id: "terminal-session:main", + title: "Main shell", + subtitle: "Running · workspace", + state: "on", + }); + expect(menu[0]?.subactions?.[1]).toMatchObject({ + id: "closed", + attributes: { disabled: true }, + }); + }); + + it("resolves nested action IDs without dispatching group or missing IDs", () => { + expect(findHeaderMenuAction(items, "terminal-session:main")).toMatchObject({ + title: "Main shell", + selected: true, + }); + expect(findHeaderMenuAction(items, "sessions")).toBeUndefined(); + expect(findHeaderMenuAction(items, "removed-session")).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/components/nativeHeaderMenu.ios.tsx b/apps/mobile/src/components/nativeHeaderMenu.ios.tsx new file mode 100644 index 000000000000..b02d10b6b697 --- /dev/null +++ b/apps/mobile/src/components/nativeHeaderMenu.ios.tsx @@ -0,0 +1,44 @@ +import { NativeHeaderToolbar } from "../native/StackHeader"; +import type { ScreenHeaderMenu, ScreenHeaderMenuItem } from "./ScreenHeader.types"; + +function renderMenuItems(items: ReadonlyArray) { + return items.map((item) => + "items" in item ? ( + + {renderMenuItems(item.items)} + + ) : ( + + {item.title} + + ), + ); +} + +/** Returns direct native items for toolbar serialization, including nested menu data. */ +export function createNativeHeaderMenu(menu: ScreenHeaderMenu) { + return ( + + {menu.status ? {menu.status} : null} + {renderMenuItems(menu.items)} + + ); +} diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 6c0d60beb5f5..5c58dfc1eaf9 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -1,6 +1,5 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { CameraView, useCameraPermissions } from "expo-camera"; -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, @@ -194,7 +193,8 @@ export function ConnectionsNewRouteScreen({ actions={[ { accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", - icon: showScanner ? "xmark" : "camera", + icon: showScanner ? "xmark" : Platform.OS === "ios" ? "qrcode.viewfinder" : "camera", + tintColor: headerIconColor, onPress: () => { if (showScanner) { closeScanner(); @@ -205,26 +205,6 @@ export function ConnectionsNewRouteScreen({ }, ]} > - - {Platform.OS !== "android" ? ( - - { - if (showScanner) { - closeScanner(); - } else { - void openScanner(); - } - }} - separateBackground - tintColor={headerIconColor} - /> - - ) : null} - navigation.navigate("SettingsSheet", { screen: "SettingsContent", @@ -90,21 +89,6 @@ export function SettingsEnvironmentsRouteScreen() { }, ]} > - {Platform.OS !== "android" ? ( - - - navigation.navigate("SettingsSheet", { - screen: "SettingsContent", - params: { screen: "SettingsEnvironmentNew" }, - }) - } - separateBackground - tintColor={headerIconColor} - /> - - ) : null} , "title" | "actions" | "trailing"> & { + props: Pick & { readonly children: ReactNode; /** A native form sheet already owns its rounded outer frame. */ readonly formSheet?: boolean; @@ -20,19 +19,14 @@ export function SettingsScreen( return ( - {Platform.OS === "android" ? ( - <> - - } - actions={props.actions} - trailing={props.trailing} - onBack={() => navigation.goBack()} - hideBottomBorder={!props.formSheet} - /> - - ) : null} + navigation.goBack()} + hideBottomBorder={!props.formSheet} + /> {props.formSheet ? ( props.children ) : ( diff --git a/apps/mobile/src/features/threads/ThreadGitControls.android.tsx b/apps/mobile/src/features/threads/ThreadGitControls.android.tsx new file mode 100644 index 000000000000..718513201cd3 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadGitControls.android.tsx @@ -0,0 +1,30 @@ +import type { + ThreadGitControls as IosThreadGitControls, + ThreadGitMenu as IosThreadGitMenu, + useThreadGitRightHeaderItems as useIosThreadGitRightHeaderItems, +} from "./ThreadGitControls"; + +export type { ThreadGitMenuProps } from "./ThreadGitControls"; + +type ThreadGitControlsProps = Parameters[0]; +const EMPTY_HEADER_ITEMS: ReturnType = []; + +export function useThreadGitRightHeaderItems(_props: ThreadGitControlsProps) { + return EMPTY_HEADER_ITEMS; +} + +export function useThreadGitCenterHeaderItems(_props: ThreadGitControlsProps) { + return EMPTY_HEADER_ITEMS; +} + +export function ThreadGitControls(_props: ThreadGitControlsProps) { + return null; +} + +export function ThreadGitMenu(_props: Parameters[0]) { + return null; +} + +export function useThreadGitMenuDefinition(_props: Parameters[0]) { + return null; +} diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 31b65f49353a..e3a4033cd809 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -1,3 +1,5 @@ +import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios"; +import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types"; import { EnvironmentId, type GitRunStackedActionResult, @@ -426,6 +428,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { ) : null} {showActionControls ? ( ) : null} - {showActionControls ? : null} + {showActionControls ? createNativeHeaderMenu(threadGitMenuDefinition(props, model)) : null} ); } @@ -500,43 +503,57 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { * chat header and the review screen's toolbar. */ export function ThreadGitMenu(props: ThreadGitMenuProps) { - const model = useThreadGitControlModel(props); + const menu = useThreadGitMenuDefinition(props); + return menu ? createNativeHeaderMenu(menu) : null; +} - return ( - - {}} - subtitle={compactMenuStatus(props.gitStatus)} - > - - {compactMenuBranchLabel(model.currentBranchLabel)} - - - void model.runQuickAction()} - subtitle={model.quickActionHint ?? undefined} - > - {model.quickAction.label} - - - Review changes - - - More - - - ); +/** Returns menu data because native toolbars serialize direct items rather than rendering component children. */ +export function useThreadGitMenuDefinition(props: ThreadGitMenuProps): ScreenHeaderMenu | null { + return threadGitMenuDefinition(props, useThreadGitControlModel(props)); +} + +function threadGitMenuDefinition( + props: ThreadGitMenuProps, + model: ReturnType, +): ScreenHeaderMenu { + return { + title: "Git controls", + icon: "point.topleft.down.curvedto.point.bottomright.up", + separateBackground: false, + items: [ + { + id: "git-status", + title: compactMenuBranchLabel(model.currentBranchLabel), + icon: "point.topleft.down.curvedto.point.bottomright.up", + disabled: true, + subtitle: compactMenuStatus(props.gitStatus), + onPress: () => {}, + }, + { + id: "git-quick-action", + title: model.quickAction.label, + icon: model.quickActionIcon, + disabled: model.quickAction.disabled, + subtitle: model.quickActionHint ?? undefined, + onPress: () => { + void model.runQuickAction(); + }, + }, + { + id: "git-review", + title: "Review changes", + icon: "text.bubble", + disabled: !model.isRepo, + subtitle: "Turn diffs and worktree changes", + onPress: model.openReview, + }, + { + id: "git-more", + title: "More", + icon: "ellipsis", + subtitle: "Commit, files, branches", + onPress: model.openGitInspector, + }, + ], + }; } diff --git a/apps/mobile/src/native/NativeHeaderToolbar.android.tsx b/apps/mobile/src/native/NativeHeaderToolbar.android.tsx new file mode 100644 index 000000000000..efa7b7e6813e --- /dev/null +++ b/apps/mobile/src/native/NativeHeaderToolbar.android.tsx @@ -0,0 +1,43 @@ +import type { NativeHeaderToolbar as IosNativeHeaderToolbar } from "./NativeHeaderToolbar"; + +function NativeHeaderToolbarRoot(_props: Parameters[0]) { + return null; +} + +function NativeHeaderToolbarButton(_props: Parameters[0]) { + return null; +} + +function NativeHeaderToolbarMenu(_props: Parameters[0]) { + return null; +} + +function NativeHeaderToolbarMenuAction( + _props: Parameters[0], +) { + return null; +} + +function NativeHeaderToolbarLabel(_props: Parameters[0]) { + return null; +} + +function NativeHeaderToolbarSpacer(_props: Parameters[0]) { + return null; +} + +function NativeHeaderToolbarSearchBarSlot() { + return null; +} + +// Native header item factories are iOS-only; Android owns its in-flow header. +export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { + Button: NativeHeaderToolbarButton, + Label: NativeHeaderToolbarLabel, + Menu: Object.assign(NativeHeaderToolbarMenu, { + Action: NativeHeaderToolbarMenuAction, + }), + MenuAction: NativeHeaderToolbarMenuAction, + SearchBarSlot: NativeHeaderToolbarSearchBarSlot, + Spacer: NativeHeaderToolbarSpacer, +}); diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx new file mode 100644 index 000000000000..3bab99546bb5 --- /dev/null +++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx @@ -0,0 +1,294 @@ +import { useNavigation, type ParamListBase } from "@react-navigation/native"; +import type { + NativeStackHeaderItem, + NativeStackHeaderItemMenu, + NativeStackNavigationOptions, + NativeStackNavigationProp, +} from "@react-navigation/native-stack"; +import { + Children, + isValidElement, + useLayoutEffect, + useMemo, + type ReactElement, + type ReactNode, +} from "react"; +import type { ColorValue } from "react-native"; + +function useNativeStackNavigation(): NativeStackNavigationProp | null { + return useNavigation>(); +} + +function labelFromChildren(children: ReactNode): string { + const parts: string[] = []; + Children.forEach(children, (child) => { + if (typeof child === "string" || typeof child === "number") { + parts.push(String(child)); + } else if (isValidElement<{ children?: ReactNode }>(child)) { + parts.push(labelFromChildren(child.props.children)); + } + }); + return parts.join(""); +} + +type NativeStackHeaderIcon = NonNullable< + Extract["icon"] +>; +type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { + unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; +}; + +function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { + if (typeof icon !== "string") { + return undefined; + } + return { type: "sfSymbol", name: icon as never }; +} + +type ToolbarElementProps = Record & { readonly children?: ReactNode }; + +function elementTypeName(element: ReactElement): string | undefined { + const type = element.type; + if (typeof type === "function") { + return (type as { displayName?: string; name?: string }).displayName ?? type.name; + } + return undefined; +} + +function convertMenuAction( + element: ReactElement, +): NativeStackHeaderItemMenu["menu"]["items"][number] | null { + const typeName = elementTypeName(element); + if (typeName === "NativeHeaderToolbarMenuAction") { + const label = labelFromChildren(element.props.children); + return { + type: "action", + label, + description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined, + disabled: Boolean(element.props.disabled), + icon: iconFromProp(element.props.icon), + onPress: + typeof element.props.onPress === "function" + ? (element.props.onPress as () => void) + : () => undefined, + state: element.props.isOn === true ? "on" : undefined, + destructive: Boolean(element.props.destructive), + discoverabilityLabel: + typeof element.props.discoverabilityLabel === "string" + ? element.props.discoverabilityLabel + : undefined, + }; + } + + if (typeName === "NativeHeaderToolbarMenu") { + return { + type: "submenu", + label: + typeof element.props.title === "string" + ? element.props.title + : labelFromChildren(element.props.children), + icon: iconFromProp(element.props.icon), + inline: Boolean(element.props.inline), + items: collectMenuItems(element.props.children), + }; + } + + return null; +} + +function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] { + const items: NativeStackHeaderItemMenu["menu"]["items"] = []; + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + const item = convertMenuAction(child); + if (item) { + items.push(item); + return; + } + items.push(...collectMenuItems(child.props.children)); + }); + return items; +} + +function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { + if (!isValidElement(child)) { + return null; + } + + const typeName = elementTypeName(child); + if (typeName === "NativeHeaderToolbarButton") { + return { + type: "button", + label: typeof child.props.label === "string" ? child.props.label : "", + accessibilityLabel: + typeof child.props.accessibilityLabel === "string" + ? child.props.accessibilityLabel + : undefined, + disabled: Boolean(child.props.disabled), + icon: iconFromProp(child.props.icon), + onPress: + typeof child.props.onPress === "function" + ? (child.props.onPress as () => void) + : () => undefined, + sharesBackground: !child.props.separateBackground, + tintColor: child.props.tintColor as ColorValue | undefined, + variant: "plain", + }; + } + + if (typeName === "NativeHeaderToolbarMenu") { + return { + type: "menu", + label: typeof child.props.title === "string" ? child.props.title : "", + accessibilityLabel: + typeof child.props.accessibilityLabel === "string" + ? child.props.accessibilityLabel + : undefined, + disabled: Boolean(child.props.disabled), + icon: iconFromProp(child.props.icon), + menu: { + title: typeof child.props.title === "string" ? child.props.title : undefined, + items: collectMenuItems(child.props.children), + }, + sharesBackground: !child.props.separateBackground, + tintColor: child.props.tintColor as ColorValue | undefined, + variant: "plain", + }; + } + + if (typeName === "NativeHeaderToolbarSpacer") { + return { + type: "spacing", + spacing: typeof child.props.width === "number" ? child.props.width : 8, + flexible: Boolean(child.props.flexible), + } as NativeStackHeaderItem; + } + + return null; +} + +function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { + const items: NativeStackHeaderItem[] = []; + Children.forEach(children, (child) => { + const item = convertToolbarChild(child); + if (item) { + if (item.type === "spacing") { + // Native inserts spacing items at `index`, treating a missing index + // as 0 — which would move the spacer in front of earlier siblings. + (item as { index?: number }).index = items.length; + } + items.push(item); + } + }); + return items; +} + +function NativeHeaderToolbarRoot(props: { + readonly placement?: "left" | "right" | "bottom"; + readonly children?: ReactNode; +}) { + const navigation = useNativeStackNavigation(); + const items = useMemo(() => collectToolbarItems(props.children), [props.children]); + + // Swap toolbar owners before paint so split and compact headers cannot clear each other. + useLayoutEffect(() => { + if (!navigation) { + return; + } + if (props.placement === "bottom") { + navigation.setOptions({ + unstable_headerToolbarItems: () => items, + } as NativeStackOptionsWithToolbar); + return () => { + navigation.setOptions({ + unstable_headerToolbarItems: () => [], + } as NativeStackOptionsWithToolbar); + }; + } + if (props.placement === "left") { + navigation.setOptions({ unstable_headerLeftItems: () => items }); + return () => { + navigation.setOptions({ unstable_headerLeftItems: () => [] }); + }; + } + navigation.setOptions({ unstable_headerRightItems: () => items }); + return () => { + navigation.setOptions({ unstable_headerRightItems: () => [] }); + }; + }, [items, navigation, props.placement]); + + return null; +} + +function NativeHeaderToolbarButton(_props: { + readonly accessibilityLabel?: string; + readonly disabled?: boolean; + readonly icon?: string; + readonly label?: string; + readonly onPress?: () => void; + readonly separateBackground?: boolean; + readonly tintColor?: ColorValue; +}) { + return null; +} +NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton"; + +function NativeHeaderToolbarMenu(_props: { + readonly accessibilityLabel?: string; + readonly children?: ReactNode; + readonly disabled?: boolean; + readonly icon?: string; + readonly inline?: boolean; + readonly separateBackground?: boolean; + readonly tintColor?: ColorValue; + readonly title?: string; +}) { + return null; +} +NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu"; + +function NativeHeaderToolbarMenuAction(_props: { + readonly children?: ReactNode; + readonly destructive?: boolean; + readonly disabled?: boolean; + readonly discoverabilityLabel?: string; + readonly icon?: string; + readonly isOn?: boolean; + readonly onPress?: () => void; + readonly subtitle?: string; +}) { + return null; +} +NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction"; + +function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { + return null; +} +NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; + +function NativeHeaderToolbarSpacer(_props: { + readonly flexible?: boolean; + readonly sharesBackground?: boolean; + readonly width?: number; +}) { + return null; +} +NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer"; + +function NativeHeaderToolbarSearchBarSlot() { + return null; +} +NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot"; + +export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { + Button: NativeHeaderToolbarButton, + Label: NativeHeaderToolbarLabel, + Menu: Object.assign(NativeHeaderToolbarMenu, { + Action: NativeHeaderToolbarMenuAction, + }), + MenuAction: NativeHeaderToolbarMenuAction, + SearchBarSlot: NativeHeaderToolbarSearchBarSlot, + Spacer: NativeHeaderToolbarSpacer, +}); diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index a524d5152473..4f8eadcc5a11 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -1,22 +1,13 @@ import { useNavigation, type ParamListBase } from "@react-navigation/native"; import type { - NativeStackHeaderItem, - NativeStackHeaderItemMenu, NativeStackNavigationOptions, NativeStackNavigationProp, } from "@react-navigation/native-stack"; -import { - Children, - isValidElement, - useEffect, - useLayoutEffect, - useMemo, - useRef, - type ReactElement, - type ReactNode, -} from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { ColorValue } from "react-native"; +export { NativeHeaderToolbar } from "./NativeHeaderToolbar"; + export { nativeHeaderScrollEdgeEffects, nativeTopScrollEdgeEffect, @@ -196,277 +187,3 @@ export function NativeStackScreenOptions(props: { return null; } - -function labelFromChildren(children: ReactNode): string { - const parts: string[] = []; - Children.forEach(children, (child) => { - if (typeof child === "string" || typeof child === "number") { - parts.push(String(child)); - } else if (isValidElement<{ children?: ReactNode }>(child)) { - parts.push(labelFromChildren(child.props.children)); - } - }); - return parts.join(""); -} - -type NativeStackHeaderIcon = NonNullable< - Extract["icon"] ->; -type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { - unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; -}; - -function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { - if (typeof icon !== "string") { - return undefined; - } - return { type: "sfSymbol", name: icon as never }; -} - -type ToolbarElementProps = Record & { readonly children?: ReactNode }; - -function elementTypeName(element: ReactElement): string | undefined { - const type = element.type; - if (typeof type === "function") { - return (type as { displayName?: string; name?: string }).displayName ?? type.name; - } - return undefined; -} - -function convertMenuAction( - element: ReactElement, -): NativeStackHeaderItemMenu["menu"]["items"][number] | null { - const typeName = elementTypeName(element); - if (typeName === "NativeHeaderToolbarMenuAction") { - const label = labelFromChildren(element.props.children); - return { - type: "action", - label, - description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined, - disabled: Boolean(element.props.disabled), - icon: iconFromProp(element.props.icon), - onPress: - typeof element.props.onPress === "function" - ? (element.props.onPress as () => void) - : () => undefined, - state: element.props.isOn === true ? "on" : undefined, - destructive: Boolean(element.props.destructive), - discoverabilityLabel: - typeof element.props.discoverabilityLabel === "string" - ? element.props.discoverabilityLabel - : undefined, - }; - } - - if (typeName === "NativeHeaderToolbarMenu") { - return { - type: "submenu", - label: - typeof element.props.title === "string" - ? element.props.title - : labelFromChildren(element.props.children), - icon: iconFromProp(element.props.icon), - inline: Boolean(element.props.inline), - items: collectMenuItems(element.props.children), - }; - } - - return null; -} - -function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] { - const items: NativeStackHeaderItemMenu["menu"]["items"] = []; - Children.forEach(children, (child) => { - if (!isValidElement(child)) { - return; - } - const item = convertMenuAction(child); - if (item) { - items.push(item); - return; - } - items.push(...collectMenuItems(child.props.children)); - }); - return items; -} - -function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { - if (!isValidElement(child)) { - return null; - } - - const typeName = elementTypeName(child); - if (typeName === "NativeHeaderToolbarButton") { - return { - type: "button", - label: typeof child.props.label === "string" ? child.props.label : "", - accessibilityLabel: - typeof child.props.accessibilityLabel === "string" - ? child.props.accessibilityLabel - : undefined, - disabled: Boolean(child.props.disabled), - icon: iconFromProp(child.props.icon), - onPress: - typeof child.props.onPress === "function" - ? (child.props.onPress as () => void) - : () => undefined, - sharesBackground: !child.props.separateBackground, - tintColor: child.props.tintColor as ColorValue | undefined, - variant: "plain", - }; - } - - if (typeName === "NativeHeaderToolbarMenu") { - return { - type: "menu", - label: typeof child.props.title === "string" ? child.props.title : "", - accessibilityLabel: - typeof child.props.accessibilityLabel === "string" - ? child.props.accessibilityLabel - : undefined, - disabled: Boolean(child.props.disabled), - icon: iconFromProp(child.props.icon), - menu: { - title: typeof child.props.title === "string" ? child.props.title : undefined, - items: collectMenuItems(child.props.children), - }, - sharesBackground: !child.props.separateBackground, - tintColor: child.props.tintColor as ColorValue | undefined, - variant: "plain", - }; - } - - if (typeName === "NativeHeaderToolbarSpacer") { - return { - type: "spacing", - spacing: typeof child.props.width === "number" ? child.props.width : 8, - flexible: Boolean(child.props.flexible), - } as NativeStackHeaderItem; - } - - return null; -} - -function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { - const items: NativeStackHeaderItem[] = []; - Children.forEach(children, (child) => { - const item = convertToolbarChild(child); - if (item) { - if (item.type === "spacing") { - // Native inserts spacing items at `index`, treating a missing index - // as 0 — which would move the spacer in front of earlier siblings. - (item as { index?: number }).index = items.length; - } - items.push(item); - } - }); - return items; -} - -function NativeHeaderToolbarRoot(props: { - readonly placement?: "left" | "right" | "bottom"; - readonly children?: ReactNode; -}) { - const navigation = useNativeStackNavigation(); - const items = useMemo(() => collectToolbarItems(props.children), [props.children]); - - // Swap toolbar owners before paint so split and compact headers cannot clear each other. - useLayoutEffect(() => { - if (!navigation) { - return; - } - if (props.placement === "bottom") { - navigation.setOptions({ - unstable_headerToolbarItems: () => items, - } as NativeStackOptionsWithToolbar); - return () => { - navigation.setOptions({ - unstable_headerToolbarItems: () => [], - } as NativeStackOptionsWithToolbar); - }; - } - if (props.placement === "left") { - navigation.setOptions({ unstable_headerLeftItems: () => items }); - return () => { - navigation.setOptions({ unstable_headerLeftItems: () => [] }); - }; - } - navigation.setOptions({ unstable_headerRightItems: () => items }); - return () => { - navigation.setOptions({ unstable_headerRightItems: () => [] }); - }; - }, [items, navigation, props.placement]); - - return null; -} - -function NativeHeaderToolbarButton(_props: { - readonly accessibilityLabel?: string; - readonly disabled?: boolean; - readonly icon?: string; - readonly label?: string; - readonly onPress?: () => void; - readonly separateBackground?: boolean; - readonly tintColor?: ColorValue; -}) { - return null; -} -NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton"; - -function NativeHeaderToolbarMenu(_props: { - readonly accessibilityLabel?: string; - readonly children?: ReactNode; - readonly disabled?: boolean; - readonly icon?: string; - readonly inline?: boolean; - readonly separateBackground?: boolean; - readonly tintColor?: ColorValue; - readonly title?: string; -}) { - return null; -} -NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu"; - -function NativeHeaderToolbarMenuAction(_props: { - readonly children?: ReactNode; - readonly destructive?: boolean; - readonly disabled?: boolean; - readonly discoverabilityLabel?: string; - readonly icon?: string; - readonly isOn?: boolean; - readonly onPress?: () => void; - readonly subtitle?: string; -}) { - return null; -} -NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction"; - -function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { - return null; -} -NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; - -function NativeHeaderToolbarSpacer(_props: { - readonly flexible?: boolean; - readonly sharesBackground?: boolean; - readonly width?: number; -}) { - return null; -} -NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer"; - -function NativeHeaderToolbarSearchBarSlot() { - return null; -} -NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot"; - -export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { - Button: NativeHeaderToolbarButton, - Label: NativeHeaderToolbarLabel, - Menu: Object.assign(NativeHeaderToolbarMenu, { - Action: NativeHeaderToolbarMenuAction, - }), - MenuAction: NativeHeaderToolbarMenuAction, - SearchBarSlot: NativeHeaderToolbarSearchBarSlot, - Spacer: NativeHeaderToolbarSpacer, -}); From 989bde889dd119f5919ce27c9d56db77efedec4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:15 -0700 Subject: [PATCH 024/111] refactor(mobile): configure thread headers through the shared core (#12389) --- .../features/threads/ThreadRouteScreen.tsx | 327 +++++++----------- .../threads/useThreadHeaderOptions.android.ts | 11 + .../threads/useThreadHeaderOptions.tsx | 119 +++++++ 3 files changed, 250 insertions(+), 207 deletions(-) create mode 100644 apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts create mode 100644 apps/mobile/src/features/threads/useThreadHeaderOptions.tsx diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 9d0410c7e9c2..e5bfc1590cf0 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -6,7 +6,10 @@ import { } from "../../state/use-composer-drafts"; import { useWorktreeSetup } from "./use-worktree-setup"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import { ScreenHeaderButton } from "../../components/ScreenHeaderButton"; +import type { ScreenHeaderAction } from "../../components/ScreenHeader.types"; +import { useThreadHeaderOptions } from "./useThreadHeaderOptions"; import { StackActions, useFocusEffect, @@ -43,12 +46,6 @@ import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; -import { - AndroidHeaderIconButton, - AndroidScreenHeader, - type AndroidHeaderAction, -} from "../../components/AndroidScreenHeader"; -import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; import { LoadingScreen } from "../../components/LoadingScreen"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; @@ -73,11 +70,6 @@ import { } from "../terminal/terminalLaunchContext"; import { terminalDebugLog } from "../terminal/terminalDebugLog"; import { ThreadDetailScreen, type ThreadDetailScreenProps } from "./ThreadDetailScreen"; -import { - ThreadGitControls, - useThreadGitCenterHeaderItems, - useThreadGitRightHeaderItems, -} from "./ThreadGitControls"; import { GitOverviewSheet } from "./git/GitOverviewSheet"; import { useAtomCommand } from "../../state/use-atom-command"; import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; @@ -93,7 +85,6 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; -import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane"; import { ThreadInspectorContentStack, @@ -101,13 +92,109 @@ import { } from "./thread-inspector-content-stack"; import { threadRouteIsHydrating } from "./thread-route-hydration"; +function ThreadHeader( + props: Parameters[0] & { + readonly hasThreadCwd: boolean; + readonly hasWorkspaceRoot: boolean; + readonly fileInspectorSupported: boolean; + readonly inspectorMode: ThreadInspectorMode | null; + readonly onToggleInspector: () => void; + readonly onOpenGitInspector: () => void; + readonly onOpenFilesInspector: () => void; + }, +) { + const navigation = useNavigation(); + const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { onOpenTerminal } = props.gitControls; + const native = useThreadHeaderOptions(props); + const androidHeaderActions = useMemo>(() => { + const actions: ScreenHeaderAction[] = []; + if (props.onReturnToThread) { + actions.push({ + accessibilityLabel: "Return to chat", + icon: "chevron.left", + onPress: props.onReturnToThread, + }); + } + if (props.hasThreadCwd) { + const filesVisible = props.inspectorMode === "files" && panes.auxiliaryPaneVisible; + actions.push({ + accessibilityLabel: filesVisible ? "Close files" : "Open files", + selected: filesVisible, + icon: "folder", + onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector, + }); + } + if (props.hasWorkspaceRoot) { + actions.push({ + accessibilityLabel: "Open terminal", + icon: "terminal", + onPress: () => onOpenTerminal(null), + }); + } + actions.push({ + accessibilityLabel: "Open git controls", + icon: "point.topleft.down.curvedto.point.bottomright.up", + onPress: props.onOpenGitInspector, + }); + return actions; + }, [ + props.inspectorMode, + panes.auxiliaryPaneVisible, + props.onOpenFilesInspector, + onOpenTerminal, + props.onOpenGitInspector, + toggleAuxiliaryPane, + props.onReturnToThread, + props.hasThreadCwd, + props.hasWorkspaceRoot, + ]); + + return ( + <> + + ) : null + } + onBack={ + layout.usesSplitView + ? undefined + : () => { + // A deep link or cold start has no previous route; Home is the way out. + // Read the history at press time: it changes without re-rendering this screen. + if (navigation.canGoBack()) navigation.goBack(); + else navigation.dispatch(StackActions.replace("Home")); + } + } + actions={androidHeaderActions} + hideBottomBorder + /> + {native.fallback} + + ); +} + interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; readonly mode: ThreadInspectorMode; } -type NativeHeaderItems = ReadonlyArray>; - function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -237,14 +324,8 @@ function ThreadRouteContent( ) { const { themeVariables } = useAppearancePreferences(); const headerColor = themeVariables["--color-header"]; - const { - fileInspector, - layout, - panes, - showAuxiliaryPane, - toggleAuxiliaryPane, - togglePrimarySidebar, - } = useAdaptiveWorkspaceLayout(); + const { fileInspector, layout, panes, showAuxiliaryPane, toggleAuxiliaryPane } = + useAdaptiveWorkspaceLayout(); const { connectionState } = useRemoteConnectionStatus(); const { onReconnectEnvironment } = useRemoteConnections(); const { @@ -701,94 +782,6 @@ function ThreadRouteContent( onPull: gitActions.onPullSelectedThreadBranch, onRunAction: gitActions.onRunSelectedThreadGitAction, }; - const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps); - const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps); - const splitLeftHeaderItems = useMemo( - () => [ - { - // Match Mail's split-view detail toolbar: the first detail action sits - // inside the content pane, not flush against the sidebar divider. - spacing: 18, - type: "spacing" as const, - }, - ...(props.onReturnToThread - ? [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Return to chat", - icon: { name: "chevron.left", type: "sfSymbol" as const }, - identifier: "thread-left-return", - onPress: props.onReturnToThread, - type: "button" as const, - }), - ] - : []), - withNativeGlassHeaderItem({ - accessibilityLabel: panes.primarySidebarVisible - ? "Maximize content" - : "Show thread sidebar", - icon: { - name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", - type: "sfSymbol" as const, - }, - identifier: "thread-left-sidebar", - onPress: togglePrimarySidebar, - type: "button" as const, - }), - withNativeGlassHeaderItem({ - accessibilityLabel: "New task", - icon: { name: "square.and.pencil", type: "sfSymbol" as const }, - identifier: "thread-left-new-task", - onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), - type: "button" as const, - }), - ], - [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], - ); - const androidHeaderActions = useMemo>(() => { - if (Platform.OS !== "android") return []; - - const actions: AndroidHeaderAction[] = []; - if (props.onReturnToThread) { - actions.push({ - accessibilityLabel: "Return to chat", - icon: "chevron.left", - onPress: props.onReturnToThread, - }); - } - if (selectedThreadCwd !== null) { - const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible; - actions.push({ - accessibilityLabel: filesVisible ? "Close files" : "Open files", - selected: filesVisible, - icon: "folder", - onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector, - }); - } - if (selectedThreadProject?.workspaceRoot) { - actions.push({ - accessibilityLabel: "Open terminal", - icon: "terminal", - onPress: () => handleOpenTerminal(null), - }); - } - actions.push({ - accessibilityLabel: "Open git controls", - icon: "point.topleft.down.curvedto.point.bottomright.up", - onPress: handleOpenGitInspector, - }); - return actions; - }, [ - inspectorMode, - panes.auxiliaryPaneVisible, - handleOpenFilesInspector, - handleOpenTerminal, - handleOpenGitInspector, - toggleAuxiliaryPane, - props.onReturnToThread, - selectedThreadCwd, - selectedThreadProject?.workspaceRoot, - ]); - const handleEditFailedCreation = useCallback(async () => { const creation = selectedThreadCreation?.message; if (!creation?.creation || routeThreadIdentity === null) { @@ -929,23 +922,6 @@ function ThreadRouteContent( preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree", }; })(); - // Deep links / cold starts land with Thread as the ONLY route, where the - // native back button does not render. Provide an explicit Home escape for - // that case; when history exists the native back button is used instead. - const canGoBack = navigation.canGoBack(); - const compactHomeHeaderItems = useMemo( - () => [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Go to threads list", - icon: { name: "list.bullet", type: "sfSymbol" as const }, - identifier: "thread-left-home", - onPress: () => navigation.dispatch(StackActions.replace("Home")), - type: "button" as const, - }), - ], - [navigation], - ); - if (!environmentId || !threadId) { return ; } @@ -967,10 +943,8 @@ function ThreadRouteContent( connectionState: routeConnectionState, }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; - const renderThreadRouteBody = (showActionControls: boolean) => ( + const renderThreadRouteBody = () => ( <> - - {activeInspectorRenderer ? : null} - splitLeftHeaderItems - : canGoBack - ? undefined - : () => compactHomeHeaderItems - : undefined, - // Search lives in the persistent sidebar, so the split header keeps - // the git controls on the RIGHT (no center items — center space is - // reserved for future breadcrumbs/status). - unstable_headerRightItems: - Platform.OS === "ios" - ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) - : undefined, - unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, - contentStyle: - Platform.OS === "android" && true ? { backgroundColor: headerColor } : undefined, - }} + - {Platform.OS === "android" ? ( - } - trailing={ - fileInspector.supported && selectedThreadCwd !== null ? ( - - ) : null - } - onBack={ - layout.usesSplitView - ? undefined - : () => { - // A deep link or cold start has no previous route; Home is the way out. - // Read the history at press time: it changes without re-rendering this screen. - if (navigation.canGoBack()) navigation.goBack(); - else navigation.dispatch(StackActions.replace("Home")); - } - } - actions={androidHeaderActions} - hideBottomBorder - /> - ) : null} - - {/* Android surfaces the git/files/inspector actions in its in-flow - header above, so the fallback action toolbar stays iOS-only. */} - {renderThreadRouteBody( - Platform.OS !== "android" && !layout.usesSplitView && !usesNativeHeaderGlass, - )} + {renderThreadRouteBody()} ); } diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts b/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts new file mode 100644 index 000000000000..7ddf716fce0f --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts @@ -0,0 +1,11 @@ +import type { useThreadHeaderOptions as useIosThreadHeaderOptions } from "./useThreadHeaderOptions"; + +export function useThreadHeaderOptions( + props: Parameters[0], +): ReturnType { + return { + options: { contentStyle: { backgroundColor: props.headerColor } }, + sidebar: true, + fallback: null, + }; +} diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx new file mode 100644 index 000000000000..b8dd5112a36e --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx @@ -0,0 +1,119 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { useMemo } from "react"; +import type { AppNativeStackNavigationOptions } from "../../native/StackHeader"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; +import { + ThreadGitControls, + useThreadGitCenterHeaderItems, + useThreadGitRightHeaderItems, +} from "./ThreadGitControls"; + +type NativeHeaderItems = ReadonlyArray>; + +export function useThreadHeaderOptions(props: { + readonly title: string; + readonly subtitle: string; + readonly headerColor: string; + readonly usesNativeHeaderGlass: boolean; + readonly gitControls: Parameters[0]; + readonly onReturnToThread?: () => void; +}) { + const navigation = useNavigation(); + const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); + const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls); + const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls); + const splitLeftHeaderItems = useMemo( + () => [ + { + // Match Mail's split-view detail toolbar: the first detail action sits + // inside the content pane, not flush against the sidebar divider. + spacing: 18, + type: "spacing" as const, + }, + ...(props.onReturnToThread + ? [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Return to chat", + icon: { name: "chevron.left", type: "sfSymbol" as const }, + identifier: "thread-left-return", + onPress: props.onReturnToThread, + type: "button" as const, + }), + ] + : []), + withNativeGlassHeaderItem({ + accessibilityLabel: panes.primarySidebarVisible + ? "Maximize content" + : "Show thread sidebar", + icon: { + name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left", + type: "sfSymbol" as const, + }, + identifier: "thread-left-sidebar", + onPress: togglePrimarySidebar, + type: "button" as const, + }), + withNativeGlassHeaderItem({ + accessibilityLabel: "New task", + icon: { name: "square.and.pencil", type: "sfSymbol" as const }, + identifier: "thread-left-new-task", + onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }), + type: "button" as const, + }), + ], + [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], + ); + // Deep links / cold starts land with Thread as the ONLY route, where the + // native back button does not render. Provide an explicit Home escape for + // that case; when history exists the native back button is used instead. + const canGoBack = navigation.canGoBack(); + const compactHomeHeaderItems = useMemo( + () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Go to threads list", + icon: { name: "list.bullet", type: "sfSymbol" as const }, + identifier: "thread-left-home", + onPress: () => navigation.dispatch(StackActions.replace("Home")), + type: "button" as const, + }), + ], + [navigation], + ); + + const options: AppNativeStackNavigationOptions = { + headerShown: true, + headerTitle: props.title, + headerTitleStyle: props.usesNativeHeaderGlass + ? { + fontSize: 17, + fontWeight: "800", + } + : undefined, + title: props.title, + headerBackVisible: !layout.usesSplitView, + // Compact uses the NATIVE back button when a previous route exists; + // deep links / cold starts get an explicit Home button instead. + // Split view always uses its custom left items. + unstable_headerLeftItems: layout.usesSplitView + ? () => splitLeftHeaderItems + : canGoBack + ? undefined + : () => compactHomeHeaderItems, + // Search lives in the persistent sidebar, so the split header keeps + // the git controls on the RIGHT (no center items — center space is + // reserved for future breadcrumbs/status). + unstable_headerRightItems: () => + layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems, + unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined, + contentStyle: undefined, + }; + return { + options, + sidebar: false, + fallback: + !layout.usesSplitView && !props.usesNativeHeaderGlass ? ( + + ) : null, + }; +} From 54bbed660fc5dc99c6bfe335981e6d431275c91e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:15 -0700 Subject: [PATCH 025/111] refactor(mobile): share file header actions and search configuration (#12390) --- .../features/files/ThreadFilesRouteScreen.tsx | 327 +++++++----------- 1 file changed, 121 insertions(+), 206 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 2cfe50de830a..9e89ea1b24e4 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,6 +1,5 @@ -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import type { MenuAction } from "@react-native-menu/menu"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -14,10 +13,8 @@ import { } from "@t3tools/shared/filePreview"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; -import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { AudioFilePreview } from "../../components/AudioFilePreview"; -import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { LoadingScreen } from "../../components/LoadingScreen"; @@ -38,14 +35,6 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; -import { - createNativeMailSearchToolbarItem, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, -} from "../layout/native-mail-search-toolbar"; -import { - AndroidWorkspaceSidebarButton, - WorkspaceSidebarToolbar, -} from "../layout/workspace-sidebar-toolbar"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback"; @@ -55,7 +44,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; -import { MaterialFilesHeader } from "./MaterialFilesHeader"; +import { ScreenHeader } from "../../components/ScreenHeader"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError"; import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; @@ -70,6 +59,107 @@ import { } from "./filePath"; import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl"; +function FilesBrowserHeader(props: { + readonly projectName: string; + readonly searchQuery: string; + readonly onSearchQueryChange: (query: string) => void; + readonly onRefresh: () => void; + readonly onBack: () => void; +}) { + return ( + + ); +} + +function FileHeader(props: { + readonly title: string; + readonly subtitle: string; + readonly iconColor: string; + readonly activeMode: string; + readonly fileInspectorSupported: boolean; + readonly onBack: () => void; + readonly onReturnToThread: () => void; + readonly actions: ReadonlyArray<{ + readonly id: string; + readonly title: string; + readonly icon: string; + readonly inline: boolean; + readonly onPress: () => unknown; + }>; +}) { + const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const modes = props.actions.filter(({ inline }) => inline); + return ( + 0 + ? [ + { + id: "modes", + inline: true, + items: modes.map((action) => ({ + ...action, + selected: action.id === props.activeMode, + })), + }, + ] + : []), + ...props.actions.filter(({ inline }) => !inline), + ], + }, + ]} + /> + ); +} + type FileViewMode = "preview" | "source"; function firstRouteParam(value: string | string[] | undefined): string | null { @@ -315,14 +405,10 @@ function FilesToolbarBottomFade() { export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { useAdaptiveWorkspacePaneRole("inspector"); const navigation = useNavigation(); - const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } = - useAdaptiveWorkspaceLayout(); + const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); - const isAndroid = Platform.OS === "android"; const { themeAppearance: highlightTheme } = useAppearancePreferences(); - const theme = useUniwindTheme(); - const headerColor = theme["--color-header"]; - const sheetSurfaceColor = theme["--color-sheet-solid"]; + const headerColor = useUniwindTheme()["--color-header"]; const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -430,84 +516,15 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); } - const usesCompactMailToolbar = - Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; - const content = ( <> - {/* Static header config (glass preset and title) lives in Stack.tsx. The - live sheet color stays dynamic here so the FlatList can remain the - direct scene child for native scroll-edge sampling. */} - 0 ? projectName : undefined, - // No refresh button: the list already supports pull-to-refresh. - unstable_headerToolbarItems: usesCompactMailToolbar - ? () => [ - createNativeMailSearchToolbarItem({ - onSearchTextChange: setSearchQuery, - placeholder: "Search files", - searchTextChangeId: "files-search-text", - }), - ] - : undefined, - headerSearchBarOptions: usesCompactMailToolbar - ? undefined - : { - allowToolbarIntegration: true, - autoCapitalize: "none", - hideNavigationBar: false, - placeholder: "Search files", - onChangeText: (event) => { - setSearchQuery(event.nativeEvent.text); - }, - onCancelButtonPress: () => { - setSearchQuery(""); - }, - }, - }} + - {isAndroid ? ( - <> - { - } - searchQuery={searchQuery} - onSearchQueryChange={setSearchQuery} - onRefresh={entriesQuery.refresh} - onBack={handleReturnToThread} - /> - } - - ) : ( - <> - {layout.usesSplitView ? ( - - - - ) : null} - {usesCompactMailToolbar ? null : ( - - - - )} - - )} ( - () => - fileMenuActions.map((action) => ({ - id: action.id, - title: action.title, - image: action.icon, - state: action.id === resolvedActiveMode ? "on" : undefined, - })), - [fileMenuActions, resolvedActiveMode], - ); - const handleAndroidFileMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - const action = fileMenuActions.find(({ id }) => id === event.nativeEvent.event); - void action?.onPress(); - }, - [fileMenuActions], - ); const handleReturnToThread = useCallback(() => { if (environmentId !== null && threadId !== null) { navigation.dispatch( @@ -878,100 +877,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { return ( - 0 ? headerSubtitle : undefined, - }} + - {isAndroid ? ( - } - hideBottomBorder - onBack={handleBack} - trailing={ - <> - {fileInspector.supported ? ( - - ) : null} - - - - - } - /> - ) : null} - - {fileInspector.supported ? ( - - ) : null} - - - {fileInspector.supported ? ( - - ) : null} - - {fileMenuActions.some(({ inline }) => inline) ? ( - - {fileMenuActions - .filter(({ inline }) => inline) - .map((action) => ( - - {action.title} - - ))} - - ) : null} - {fileMenuActions - .filter(({ inline }) => !inline) - .map((action) => ( - - {action.title} - - ))} - - Date: Fri, 18 Sep 2026 12:55:15 -0700 Subject: [PATCH 026/111] refactor(mobile): share terminal header and menu configuration (#12391) --- .../terminal/ThreadTerminalRouteScreen.tsx | 277 +++++++----------- 1 file changed, 100 insertions(+), 177 deletions(-) diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index e490200acf8d..794cb9dce476 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,8 +1,7 @@ import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; -import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { ScreenHeader } from "../../components/ScreenHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Pressable, View } from "react-native"; @@ -19,13 +18,11 @@ import { useKeyboardState, } from "react-native-keyboard-controller"; -import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, } from "../../components/ComposerToolbar"; -import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; @@ -52,8 +49,6 @@ import { import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; -import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; -import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar"; import { TerminalSurface } from "./NativeTerminalSurface"; import { getMobileTerminalTheme } from "./terminalTheme"; import { terminalDebugLog } from "./terminalDebugLog"; @@ -85,6 +80,90 @@ import { import { createTerminalPasteSession } from "./terminalPaste"; import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; +function TerminalHeader(props: { + readonly subtitle: string; + readonly isEnvironmentReady: boolean; + readonly fontSize: number; + readonly terminalId: string; + readonly sessions: ReadonlyArray; + readonly status: Parameters[0]; + readonly workspaceRoot: string; + readonly onCloseTerminal: () => void; + readonly onDecreaseFontSize: () => void; + readonly onIncreaseFontSize: () => void; + readonly onOpenNewTerminal: () => void; + readonly onSelectTerminal: (terminalId: string) => void; +}) { + return ( + = MAX_TERMINAL_FONT_SIZE, + onPress: props.onIncreaseFontSize, + }, + ], + }, + ...props.sessions.map((session) => ({ + id: `terminal-session:${session.terminalId}`, + title: session.displayLabel, + icon: "terminal", + subtitle: [ + getTerminalStatusLabel({ + status: session.status, + hasRunningSubprocess: session.hasRunningSubprocess, + }), + basename(session.cwd), + ] + .filter(Boolean) + .join(" · "), + selected: session.terminalId === props.terminalId, + onPress: () => props.onSelectTerminal(session.terminalId), + })), + { + id: "terminal-new", + title: "Open new terminal", + icon: "plus", + subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`, + onPress: props.onOpenNewTerminal, + }, + ], + }, + ] + : undefined + } + /> + ); +} + const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const TERMINAL_ACCESSORY_HEIGHT = 52; @@ -171,7 +250,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const { state: workspaceState } = useWorkspaceState(); - const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); const params = props.route.params; const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); @@ -483,7 +561,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ); const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); - const usesNativeHeaderGlass = Platform.OS === "ios"; const pendingModifier = pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; const headerSubtitle = selectedThreadProject?.title ?? ""; @@ -1003,67 +1080,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); }, [fontSize, setTerminalFontSize]); - // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text - // size, session switching, and "Open new terminal", rendered through the - // token-styled anchored menu (the native header items are iOS-only). - const androidTerminalMenuActions = useMemo( - () => [ - { - id: "text-size", - title: "Text size", - subactions: [ - { - id: "font-decrease", - title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, - attributes: fontSize <= MIN_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, - }, - { - id: "font-increase", - title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, - attributes: fontSize >= MAX_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, - }, - ], - }, - ...terminalMenuSessions.map((session): MenuAction => ({ - id: `terminal-session:${session.terminalId}`, - title: session.displayLabel, - subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] - .filter(Boolean) - .join(" · "), - state: session.terminalId === terminalId ? ("on" as const) : undefined, - })), - { - id: "terminal-new", - title: "Open new terminal", - image: "plus", - subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, - }, - ], - [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], - ); - - const handleAndroidTerminalMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - const id = event.nativeEvent.event; - if (id === "font-decrease") { - handleDecreaseFontSize(); - return; - } - if (id === "font-increase") { - handleIncreaseFontSize(); - return; - } - if (id === "terminal-new") { - handleOpenNewTerminal(); - return; - } - if (id.startsWith("terminal-session:")) { - handleSelectTerminal(id.slice("terminal-session:".length)); - } - }, - [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal], - ); - const handleClearTerminal = useCallback(() => { if (!selectedThread) { return; @@ -1169,117 +1185,24 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }} /> ) : null} - 0 ? headerSubtitle : undefined, + - {Platform.OS === "android" ? ( - } - onBack={handleCloseTerminal} - trailing={ - <> - {isEnvironmentReady ? ( - - - - ) : null} - - } - /> - ) : null} - - {layout.usesSplitView ? ( - - - - - ) : null} - - {isEnvironmentReady ? ( - - - - {getTerminalStatusLabel({ - status: terminal.status, - hasRunningSubprocess: terminal.hasRunningSubprocess, - })} - - - Text size - - {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} - - = MAX_TERMINAL_FONT_SIZE} - discoverabilityLabel="Increase terminal text size" - onPress={handleIncreaseFontSize} - > - {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} - - - {terminalMenuSessions.map((session) => ( - handleSelectTerminal(session.terminalId)} - subtitle={[ - getTerminalStatusLabel({ status: session.status }), - basename(session.cwd), - ] - .filter(Boolean) - .join(" · ")} - > - {session.displayLabel} - - ))} - - Open new terminal - - - - ) : null} - Date: Fri, 18 Sep 2026 12:55:16 -0700 Subject: [PATCH 027/111] refactor(mobile): share archived thread header configuration (#12399) --- .../archive/ArchivedThreadsScreen.tsx | 393 ++++-------------- 1 file changed, 80 insertions(+), 313 deletions(-) diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 6161c203f11d..a191a9c7175b 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -8,15 +8,12 @@ import { type EnvironmentMachineKind, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { SymbolView } from "../../components/AppSymbol"; import { useNavigation } from "@react-navigation/native"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useMemo, useRef, type ComponentProps } from "react"; import { - TextInput, ActivityIndicator, - Platform, Pressable, RefreshControl, useWindowDimensions, @@ -26,19 +23,13 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { AppText as Text } from "../../components/AppText"; -import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useServerConfigs } from "../../state/entities"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { - createNativeMailSearchToolbarItem, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, -} from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; import { SettingsScreenContent } from "../settings/components/SettingsScreen"; @@ -47,23 +38,6 @@ export interface ArchivedThreadsHeaderEnvironment { readonly label: string; } -type ArchivedThreadListItem = - | { - readonly kind: "project"; - readonly key: string; - readonly environmentLabel: string | null; - readonly environmentMachine: EnvironmentMachineKind; - readonly project: EnvironmentProject; - } - | { - readonly kind: "thread"; - readonly key: string; - readonly environmentLabel: string | null; - readonly isFirst: boolean; - readonly isLast: boolean; - readonly thread: EnvironmentThreadShell; - }; - function ArchivedThreadsHeader(props: { readonly environments: ReadonlyArray; readonly searchQuery: string; @@ -74,299 +48,92 @@ function ArchivedThreadsHeader(props: { readonly onSearchQueryChange: (query: string) => void; readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; }) { - const { width } = useWindowDimensions(); const navigation = useNavigation(); - const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; - const usesNativeChrome = Platform.OS === "ios"; - const usesCompactMailToolbar = - Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; - const androidFilterActions = useMemo( - () => [ - { - id: "environment", - title: "Environment", - subactions: [ - { - id: "environment:all", - title: "All environments", - state: props.selectedEnvironmentId === null ? ("on" as const) : undefined, - }, - ...props.environments.map((environment) => ({ - id: `environment:${environment.environmentId}`, - title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : undefined, - })), - ], - }, - { - id: "sort", - title: "Sort by archived date", - subactions: [ - { - id: "sort:newest", - title: "Newest first", - state: props.sortOrder === "newest" ? ("on" as const) : undefined, - }, - { - id: "sort:oldest", - title: "Oldest first", - state: props.sortOrder === "oldest" ? ("on" as const) : undefined, - }, - ], - }, - ], - [props.environments, props.selectedEnvironmentId, props.sortOrder], - ); - const handleAndroidFilterAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - const action = event.nativeEvent.event; - if (action === "environment:all") { - props.onEnvironmentChange(null); - } else if (action.startsWith("environment:")) { - props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId); - } else if (action === "sort:newest") { - props.onSortOrderChange("newest"); - } else if (action === "sort:oldest") { - props.onSortOrderChange("oldest"); - } - }, - [props.onEnvironmentChange, props.onSortOrderChange], - ); - - if (Platform.OS === "android") { - // Single header row matching the app's Android chrome (AndroidScreenHeader - // palette): back chevron, inline search, filter menu. - return ( - <> - - - - navigation.goBack()} - className="size-11 items-center justify-center" - > - - - - - - - - - - - - - - - ); - } - const archiveFilterMenu = { - title: "Archived thread options", - items: [ - { - type: "submenu" as const, - title: "Environment", - items: [ - { - type: "action" as const, - title: "All environments", - state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const), - onPress: () => props.onEnvironmentChange(null), - }, - ...props.environments.map((environment) => ({ - type: "action" as const, - title: environment.label, - state: - props.selectedEnvironmentId === environment.environmentId - ? ("on" as const) - : ("off" as const), - onPress: () => props.onEnvironmentChange(environment.environmentId), - })), - ], - }, - { - type: "submenu" as const, - title: "Sort by archived date", - items: [ - { - type: "action" as const, - title: "Newest first", - state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const), - onPress: () => props.onSortOrderChange("newest"), - }, - { - type: "action" as const, - title: "Oldest first", - state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const), - onPress: () => props.onSortOrderChange("oldest"), - }, - ], - }, - ], - }; - return ( - <> - {/* Static header config (glass preset + title) lives in Stack.tsx; only - dynamic toolbar/search wiring is set here. */} - [ - createNativeMailSearchToolbarItem({ - composeButtonId: "archived-refresh", - composeSystemImageName: "arrow.clockwise", - filterMenu: archiveFilterMenu, - filterButtonId: "archived-filter", - filterSystemImageName: hasCustomFilter - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onRefresh, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "archived-search-text", - }), - ] - : undefined, - headerSearchBarOptions: usesCompactMailToolbar - ? undefined - : { - ...(usesNativeChrome - ? { - allowToolbarIntegration: true, - // "integratedButton" is an iOS 26 search-bar placement; - // pre-glass iOS keeps the default pull-down placement. - ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED - ? { placement: "integratedButton" as const } - : null), - } - : { - placement: "stacked" as const, - }), - autoCapitalize: "none", - hideNavigationBar: false, - obscureBackground: false, - placeholder: "Search archived threads", - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); + navigation.goBack()} + search={{ + value: props.searchQuery, + onChangeText: props.onSearchQueryChange, + placeholder: "Search archived threads", + compactPlaceholder: "Search", + mode: "inline", + compactToolbar: width < 700, + onRefresh: props.onRefresh, + refreshInToolbar: true, + refreshAccessibilityLabel: "Refresh archived threads", + }} + menus={[ + { + title: "Archived thread options", + icon: hasCustomFilter + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease.circle", + items: [ + { + id: "environment", + title: "Environment", + items: [ + { + id: "environment:all", + title: "All environments", + selected: props.selectedEnvironmentId === null, + onPress: () => props.onEnvironmentChange(null), }, - onCancelButtonPress: () => { - props.onSearchQueryChange(""); + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + selected: props.selectedEnvironmentId === environment.environmentId, + onPress: () => props.onEnvironmentChange(environment.environmentId), + })), + ], + }, + { + id: "sort", + title: "Sort by archived date", + items: [ + { + id: "sort:newest", + title: "Newest first", + selected: props.sortOrder === "newest", + onPress: () => props.onSortOrderChange("newest"), }, - }, - }} - /> - - {usesCompactMailToolbar ? null : ( - - {usesNativeChrome ? ( - - ) : null} - - - Environment - props.onEnvironmentChange(null)} - > - All environments - - {props.environments.map((environment) => ( - props.onEnvironmentChange(environment.environmentId)} - > - {environment.label} - - ))} - - - - Sort by archived date - props.onSortOrderChange("newest")} - > - Newest first - - props.onSortOrderChange("oldest")} - > - Oldest first - - - - - )} - + { + id: "sort:oldest", + title: "Oldest first", + selected: props.sortOrder === "oldest", + onPress: () => props.onSortOrderChange("oldest"), + }, + ], + }, + ], + }, + ]} + /> ); } +type ArchivedThreadListItem = + | { + readonly kind: "project"; + readonly key: string; + readonly environmentLabel: string | null; + readonly environmentMachine: EnvironmentMachineKind; + readonly project: EnvironmentProject; + } + | { + readonly kind: "thread"; + readonly key: string; + readonly environmentLabel: string | null; + readonly isFirst: boolean; + readonly isLast: boolean; + readonly thread: EnvironmentThreadShell; + }; + function ProjectGroupLabel(props: { readonly environmentLabel: string | null; readonly environmentMachine: EnvironmentMachineKind; From 88b9226d229a7d51283122a8b8b8be8305822a9e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:16 -0700 Subject: [PATCH 028/111] refactor(mobile): compose review menus through the shared header (#12400) --- .../src/features/review/ReviewSheet.tsx | 332 ++++++------------ .../useReviewHeaderPresentation.android.ts | 20 ++ .../review/useReviewHeaderPresentation.tsx | 60 ++++ 3 files changed, 190 insertions(+), 222 deletions(-) create mode 100644 apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts create mode 100644 apps/mobile/src/features/review/useReviewHeaderPresentation.tsx diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 0035f91e2d18..cb55570f9116 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -1,11 +1,10 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { - NativeHeaderToolbar, - NativeStackScreenOptions, - nativeHeaderScrollEdgeEffects, -} from "../../native/StackHeader"; +import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; +import { ScreenHeader } from "../../components/ScreenHeader"; +import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types"; +import type { ReviewSectionItem } from "./reviewModel"; +import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation"; import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { memo, @@ -33,8 +32,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; -import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { ControlPillMenu } from "../../components/ControlPill"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { cn } from "../../lib/cn"; import { environmentCatalog } from "../../connection/catalog"; @@ -50,17 +48,7 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; -import { useEnvironmentQuery } from "../../state/query"; -import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; -import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; -import { useThreadSelection } from "../../state/use-thread-selection"; -import { vcsEnvironment } from "../../state/vcs"; -import { - AndroidWorkspaceSidebarButton, - WorkspaceSidebarToolbar, -} from "../layout/workspace-sidebar-toolbar"; -import { ThreadGitMenu } from "../threads/ThreadGitControls"; import { useReviewCacheForThread } from "./reviewState"; import { isNativeReviewDiffDrawEvent, @@ -78,9 +66,96 @@ import { useReviewCommentSelectionController } from "./useReviewCommentSelection import { resolveReviewAvailability } from "./reviewAvailability"; import { resolveSelectedReviewFileId } from "./reviewPaneSelection"; import { buildReviewSectionMenu } from "./review-section-menu"; -import type { ReviewSectionItem } from "./reviewModel"; import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal"; +function ReviewHeader( + props: Parameters[0] & { + readonly iconColor: string; + readonly sectionMenu: ReturnType; + readonly showSectionToolbar: boolean; + readonly showChangedFilesToggle: boolean; + readonly onSelectSection: (sectionId: string) => void; + readonly onReturnToThread: () => void; + }, +) { + const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const presentation = useReviewHeaderPresentation(props); + const sectionAction = ( + section: ReviewSectionItem | null, + title: string, + ): ScreenHeaderMenuItem => ({ + id: section ? `section:${section.id}` : `unavailable:${title}`, + title, + disabled: section === null, + selected: section !== null && section.id === props.selectedSection?.id, + onPress: () => { + if (section) props.onSelectSection(section.id); + }, + }); + return ( + 0 + ? [ + { + id: "turns", + title: "Turn", + items: props.sectionMenu.turns.map((section) => ({ + id: `section:${section.id}`, + title: section.title, + subtitle: section.subtitle ?? undefined, + selected: section.id === props.selectedSection?.id, + onPress: () => props.onSelectSection(section.id), + })), + }, + ] + : []), + ...(presentation.refreshAction ? [presentation.refreshAction] : []), + ], + }, + ] + : []), + ]} + /> + ); +} + const REVIEW_HEADER_SPACING = 0; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; @@ -370,10 +445,9 @@ type ReviewSheetProps = StaticScreenProps<{ }>; export function ReviewSheet(props: ReviewSheetProps) { - const isAndroid = Platform.OS === "android"; const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); useAdaptiveWorkspacePaneRole("inspector"); - const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); @@ -384,23 +458,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); - /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */ - const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); - const gitState = useSelectedThreadGitState(); - const gitActions = useSelectedThreadGitActions(); - const gitStatusQuery = useEnvironmentQuery( - selectedThread !== null && selectedThreadCwd !== null - ? vcsEnvironment.status({ - environmentId: selectedThread.environmentId, - input: { cwd: selectedThreadCwd }, - }) - : null, - ); - // The selection-based git hooks only apply when this review belongs to the - // selected thread (it always does when reached from the thread's toolbar). - const gitMenuAvailable = - selectedThread !== null && String(selectedThread.id) === String(threadId); // With a solid (non-overlay) header the content lays out below the header // natively, so no manual top inset is needed. (Android renders its own // in-flow AndroidScreenHeader, so it needs no inset either.) @@ -581,52 +639,6 @@ export function ReviewSheet(props: ReviewSheetProps) { hasCachedSelectedDiff, hasAnyCachedDiff, }); - const androidSectionMenuActions = useMemo(() => { - const sectionAction = (section: ReviewSectionItem | null, title: string): MenuAction => ({ - id: section ? `section:${section.id}` : `unavailable:${title}`, - title: section?.id === selectedSection?.id ? `${title} (selected)` : title, - attributes: section ? undefined : { disabled: true }, - }); - const actions: MenuAction[] = [ - sectionAction(sectionMenu.workingTree, "Working tree"), - sectionAction(sectionMenu.branchChanges, "Branch changes"), - sectionAction(sectionMenu.latestTurn, "Latest turn"), - ]; - - if (sectionMenu.turns.length > 0) { - actions.push({ - id: "turns", - title: "Turn", - subactions: sectionMenu.turns.map((section) => ({ - id: `section:${section.id}`, - title: section.id === selectedSection?.id ? `${section.title} (selected)` : section.title, - subtitle: section.subtitle ?? undefined, - })), - }); - } - - // The Android native diff surface has no pull-to-refresh, so refresh - // stays a menu action there (iOS refreshes via pull-to-refresh instead). - actions.push({ - id: "refresh", - title: "Refresh current diff", - attributes: { - disabled: !selectedSection || selectedSection.isLoading, - }, - }); - return actions; - }, [sectionMenu, selectedSection]); - const handleAndroidSectionMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - const id = event.nativeEvent.event; - if (id === "refresh") { - void handlePullToRefresh(); - } else if (id.startsWith("section:")) { - selectSection(id.slice("section:".length)); - } - }, - [handlePullToRefresh, selectSection], - ); const handleRetryEnvironment = useCallback(() => { void retryEnvironment(environmentId); }, [environmentId, retryEnvironment]); @@ -703,147 +715,23 @@ export function ReviewSheet(props: ReviewSheetProps) { return ( <> - 0 ? headerSubtitle : undefined, - } - } + - {isAndroid ? ( - } - hideBottomBorder - subtitle={androidHeaderSubtitle || "Select a diff"} - onBack={handleReturnToThread} - trailing={ - <> - {showChangedFilesToggle ? ( - - ) : null} - {showSectionToolbar ? ( - - - - ) : null} - - } - /> - ) : null} - - - - - - {!isAndroid && (showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable) ? ( - - {showChangedFilesToggle ? ( - - ) : null} - {gitMenuAvailable && selectedThread !== null ? ( - - ) : null} - {showSectionToolbar ? ( - - - { - if (sectionMenu.workingTree) { - selectSection(sectionMenu.workingTree.id); - } - }} - > - Working tree - - { - if (sectionMenu.branchChanges) { - selectSection(sectionMenu.branchChanges.id); - } - }} - > - Branch changes - - { - if (sectionMenu.latestTurn) { - selectSection(sectionMenu.latestTurn.id); - } - }} - > - Latest turn - - {sectionMenu.turns.length > 0 ? ( - - {sectionMenu.turns.map((section) => ( - selectSection(section.id)} - subtitle={section.subtitle ?? undefined} - > - {section.title} - - ))} - - ) : null} - - - ) : null} - - ) : null} - {showConnectionNotice ? ( diff --git a/apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts b/apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts new file mode 100644 index 000000000000..914b3545b86a --- /dev/null +++ b/apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts @@ -0,0 +1,20 @@ +import type { useReviewHeaderPresentation as useIosReviewHeaderPresentation } from "./useReviewHeaderPresentation"; + +export function useReviewHeaderPresentation( + props: Parameters[0], +): ReturnType { + return { + title: "Review changes", + subtitle: props.androidSubtitle || "Select a diff", + gitMenu: null, + menuIcon: "ellipsis.circle", + refreshAction: { + id: "refresh", + title: "Refresh current diff", + disabled: !props.selectedSection || props.selectedSection.isLoading, + onPress: () => { + void props.onRefresh(); + }, + }, + }; +} diff --git a/apps/mobile/src/features/review/useReviewHeaderPresentation.tsx b/apps/mobile/src/features/review/useReviewHeaderPresentation.tsx new file mode 100644 index 000000000000..f9ab9d794a47 --- /dev/null +++ b/apps/mobile/src/features/review/useReviewHeaderPresentation.tsx @@ -0,0 +1,60 @@ +import { useEnvironmentQuery } from "../../state/query"; +import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; +import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { vcsEnvironment } from "../../state/vcs"; +import { useThreadGitMenuDefinition } from "../threads/ThreadGitControls"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ReviewSectionItem } from "./reviewModel"; +import type { ScreenHeaderMenuItem, ScreenHeaderMenu } from "../../components/ScreenHeader.types"; +import type { AppSymbolName } from "../../components/AppSymbol"; + +interface ReviewHeaderPresentation { + readonly title: string; + readonly subtitle: string; + readonly gitMenu: ScreenHeaderMenu | null; + readonly menuIcon: AppSymbolName; + readonly refreshAction?: ScreenHeaderMenuItem; +} + +export function useReviewHeaderPresentation(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly title: string; + readonly subtitle: string; + readonly androidSubtitle: string; + readonly selectedThreadCwd: string | null; + readonly selectedSection: ReviewSectionItem | null; + readonly onRefresh: () => Promise; +}): ReviewHeaderPresentation { + const { selectedThread } = useThreadSelection(); + const gitState = useSelectedThreadGitState(); + const gitActions = useSelectedThreadGitActions(); + const gitStatusQuery = useEnvironmentQuery( + selectedThread !== null && props.selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: props.selectedThreadCwd }, + }) + : null, + ); + // The selection-based git hooks only apply when this review belongs to the + // selected thread (it always does when reached from the thread's toolbar). + const gitMenuAvailable = + selectedThread !== null && String(selectedThread.id) === String(props.threadId); + const gitMenu = useThreadGitMenuDefinition({ + environmentId: props.environmentId, + threadId: props.threadId, + currentBranch: selectedThread?.branch ?? null, + gitStatus: gitStatusQuery.data, + gitOperationLabel: gitState.gitOperationLabel, + onPull: gitActions.onPullSelectedThreadBranch, + onRunAction: gitActions.onRunSelectedThreadGitAction, + }); + return { + title: props.title, + subtitle: props.subtitle, + menuIcon: "ellipsis", + gitMenu: gitMenuAvailable ? gitMenu : null, + }; +} From 93e04160a0c0dece8a258384d2118a660415a53d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:17 -0700 Subject: [PATCH 029/111] feat(mobile): search projects when starting a task (#12496) --- .../features/threads/NewTaskRouteScreen.tsx | 126 ++++++++++-------- .../new-task-project-selection.test.ts | 29 ++++ .../threads/new-task-project-selection.ts | 17 +++ 3 files changed, 116 insertions(+), 56 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 4b987bbb1423..75210bc36331 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,5 +1,5 @@ import { MaterialListRow } from "../../components/MaterialListRow"; -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { ScreenHeader } from "../../components/ScreenHeader"; import { StackActions, useIsFocused, @@ -8,11 +8,10 @@ import { } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { MaterialScreenContent } from "../../components/MaterialScreenContent"; import { MaterialButton } from "../../components/MaterialButton"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; @@ -24,7 +23,7 @@ import { useWorkspaceState } from "../../state/workspace"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { useNewTaskFlow } from "./new-task-flow-provider"; -import { getProjectScopeSelectionTarget } from "./new-task-project-selection"; +import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection"; type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; @@ -85,13 +84,58 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { }; } +function NewTaskHeader(props: { + readonly title: string; + readonly subtitle: string | null; + readonly canAddProject: boolean; + readonly searchText: string; + readonly onSearchTextChange: (text: string) => void; +}) { + const navigation = useNavigation(); + const { layout } = useAdaptiveWorkspaceLayout(); + return ( + navigation.goBack()} + actions={[ + ...(Platform.OS === "ios" && layout.usesSplitView + ? [ + { + accessibilityLabel: "Close new task", + icon: "xmark" as const, + onPress: () => navigation.goBack(), + }, + ] + : []), + ...(props.canAddProject + ? [ + { + accessibilityLabel: "Add project", + icon: "plus" as const, + onPress: () => navigation.dispatch(StackActions.push("AddProject")), + }, + ] + : []), + ]} + search={{ + value: props.searchText, + onChangeText: props.onSearchTextChange, + placeholder: "Search projects", + }} + /> + ); +} + export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); + const [searchText, setSearchText] = useState(""); const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); const isFocused = useIsFocused(); - const { layout } = useAdaptiveWorkspaceLayout(); const insets = useSafeAreaInsets(); const { getShare, releaseShareReservation } = useIncomingShare(); const routeShareId = Array.isArray(route.params?.incomingShareId) @@ -107,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps(null); const reservedDestinationProject = incomingShare?.destination ? (projects.find( @@ -180,67 +225,27 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - {Platform.OS === "android" ? ( - <> - {/* Android renders its own in-screen header instead of the native bar. */} - - navigation.goBack()} - actions={ - catalogState.hasReadyEnvironment - ? [ - { - accessibilityLabel: "Add project", - icon: "plus", - onPress: () => navigation.dispatch(StackActions.push("AddProject")), - }, - ] - : [] - } - /> - - ) : ( - <> - - - {layout.usesSplitView ? ( - navigation.goBack()} - separateBackground - /> - ) : null} - {catalogState.hasReadyEnvironment ? ( - navigation.dispatch(StackActions.push("AddProject"))} - separateBackground - /> - ) : null} - - - )} + )} + ) : visibleScopes.length === 0 ? ( + + + No matching projects + + + Try a different project name or workspace path. + + ) : ( - {projectScopes.map((scope, scopeIndex) => { + {visibleScopes.map((scope, scopeIndex) => { const hasMultipleProjects = scope.projects.length > 1; const selectionTarget = getProjectScopeSelectionTarget( scope, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index ca59a2b9dddc..12f669530aae 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { + filterProjectScopes, getProjectScopeSelectionTarget, resolveDraftProjectSelection, resolveEnvironmentProjectMatch, @@ -152,3 +153,31 @@ describe("resolveDraftProjectSelection", () => { }); }); }); + +describe("filterProjectScopes", () => { + const mac = makeProject("code", "mac", { title: "Desktop checkout" }); + const server = makeProject("remote-code", "server", { workspaceRoot: "/srv/remote-workspace" }); + const code = makeScope([mac, server]); + const docs = { ...makeScope([makeProject("docs")]), key: "docs", title: "Documentation" }; + const scopes = [code, docs]; + + it("keeps all projects for an empty or whitespace-only query", () => { + expect(filterProjectScopes(scopes, "")).toBe(scopes); + expect(filterProjectScopes(scopes, " ")).toBe(scopes); + }); + + it("matches logical names and workspace names or paths without case sensitivity", () => { + expect(filterProjectScopes(scopes, " T3 CODE ")).toEqual([code]); + expect(filterProjectScopes(scopes, "DESKTOP")).toEqual([code]); + expect(filterProjectScopes(scopes, "REMOTE-WORKSPACE")).toEqual([code]); + expect(filterProjectScopes(scopes, "documentation")).toEqual([docs]); + expect(filterProjectScopes(scopes, "missing-project")).toEqual([]); + }); + + it("preserves the whole logical project and preferred environment when a workspace matches", () => { + const matches = filterProjectScopes(scopes, "REMOTE-WORKSPACE"); + expect(matches[0]).toBe(code); + expect(getProjectScopeSelectionTarget(matches[0]!, EnvironmentId.make("mac"))).toBe(mac); + expect(code.projects).toEqual([mac, server]); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 65dd9916f2f6..27d1303c3a45 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -19,6 +19,23 @@ export function getProjectScopeSelectionTarget( ); } +export function filterProjectScopes( + scopes: ReadonlyArray, + searchText: string, +): ReadonlyArray { + const query = searchText.trim().toLowerCase(); + if (!query) return scopes; + return scopes.filter( + (scope) => + scope.title.toLowerCase().includes(query) || + scope.projects.some( + (project) => + project.title.toLowerCase().includes(query) || + project.workspaceRoot.toLowerCase().includes(query), + ), + ); +} + function getOnlySelectableProject( projectScopes: ReadonlyArray, ): EnvironmentProject | null { From 82cd1d1aabe8baa4a5ae23f3c7b08b3fce092e08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 14:23:09 -0700 Subject: [PATCH 030/111] fix(mobile): preserve multiple model favorites (#12505) --- .../features/threads/ThreadSettingsSheet.tsx | 15 +++--- apps/mobile/src/state/preferences.test.ts | 53 ++++++++++++++++--- apps/mobile/src/state/preferences.ts | 14 +++-- 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 9dbefe6018b7..b8411229a8ac 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -47,7 +47,6 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import type { Preferences } from "../../persistence/mobile-preferences"; import { NativeHeaderToolbar, NativeStackScreenOptions, @@ -331,12 +330,14 @@ function ThreadSettingsSessionProvider( (option: ModelOption) => { if (!favoritesLoaded) return; void Haptics.selectionAsync(); - savePreferences((current: Preferences) => ({ - modelFavorites: toggleModelFavorite( - current.modelFavorites ?? EMPTY_MODEL_FAVORITES, - option, - ), - })); + savePreferences({ + transform: (current) => ({ + modelFavorites: toggleModelFavorite( + current.modelFavorites ?? EMPTY_MODEL_FAVORITES, + option, + ), + }), + }); }, [favoritesLoaded, savePreferences], ); diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index 48b7badcc2c2..9a84d70cd61a 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -5,6 +5,9 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { vi } from "vite-plus/test"; import { ProviderInstanceId } from "@t3tools/contracts"; +import { RegistryContext, useAtomSet } from "@effect/atom-react"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(), @@ -125,12 +128,16 @@ describe("mobile preferences state", () => { }), ); - it.effect("keeps both favorites when updates are sent before a render", () => + it.effect("keeps both favorites when the React setter sends updates before a render", () => Effect.gen(function* () { let persisted: Preferences = { modelFavorites: [] }; const state = makePreferencesState({ load: Effect.succeed(persisted), - savePatch: () => Effect.die(new Error("Favorite updates must use a transform.")), + savePatch: (patch) => + Effect.sync(() => { + persisted = { ...persisted, ...patch }; + return persisted; + }), update: (transform) => Effect.sync(() => { persisted = { ...persisted, ...transform(persisted) }; @@ -142,13 +149,31 @@ describe("mobile preferences state", () => { const unmountUpdate = registry.mount(state.updatePreferencesAtom); yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true }); + function useSavePreferences() { + return useAtomSet(state.updatePreferencesAtom); + } + const setters: Array> = []; + function CaptureSetter() { + setters.push(useSavePreferences()); + return null; + } + // Exercise the real React setter, which treats bare functions as updates + // to the atom's read value. Direct registry.set calls bypass that behavior. + renderToString( + createElement(RegistryContext.Provider, { value: registry }, createElement(CaptureSetter)), + ); + const savePreferences = setters[0]!; const provider = ProviderInstanceId.make("codex"); - registry.set(state.updatePreferencesAtom, (current) => ({ - modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }], - })); - registry.set(state.updatePreferencesAtom, (current) => ({ - modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }], - })); + savePreferences({ + transform: (current) => ({ + modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }], + }), + }); + savePreferences({ + transform: (current) => ({ + modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }], + }), + }); yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, { suspendOnWaiting: true, }); @@ -158,6 +183,18 @@ describe("mobile preferences state", () => { { provider, model: "sol" }, ]); + savePreferences({ + transform: (current) => ({ + modelFavorites: (current.modelFavorites ?? []).filter( + (favorite) => favorite.model !== "astra", + ), + }), + }); + yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, { + suspendOnWaiting: true, + }); + expect(persisted.modelFavorites).toEqual([{ provider, model: "sol" }]); + unmountUpdate(); unmountPreferences(); registry.dispose(); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index bcecc69db458..7baff0251c15 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -15,7 +15,11 @@ interface OptimisticPreferences { readonly versions: Partial>; } -type PreferencesUpdate = Partial | ((current: Preferences) => Partial); +// A bare function is interpreted by useAtomSet as an update to the command's +// AsyncResult, not as a preference transform. Keep transforms inside a payload. +type PreferencesUpdate = + | Partial + | { readonly transform: (current: Preferences) => Partial }; /** * Owns the device preference blob for the lifetime of the app registry. @@ -62,8 +66,10 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime { const currentPreferences = get(preferencesAtom); const patch = - typeof update === "function" - ? update(AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {}) + "transform" in update + ? update.transform( + AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {}, + ) : update; const version = ++nextPatchVersion; const current = get(optimisticPatchAtom); @@ -77,7 +83,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime - typeof update === "function" ? store.update(update) : store.savePatch(patch), + "transform" in update ? store.update(update.transform) : store.savePatch(patch), ), Effect.tap((saved) => Effect.sync(() => { From 3bb06ad910389fcfc886bd5156f41dc73f307f80 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 18 Sep 2026 18:55:15 -0400 Subject: [PATCH 031/111] feat(server): export log records over OTLP (#12493) Signed-off-by: Yordis Prieto --- apps/desktop/src/app/DesktopConfig.ts | 2 + .../src/app/DesktopEnvironment.test.ts | 4 + apps/desktop/src/app/DesktopEnvironment.ts | 4 + .../DesktopBackendConfiguration.test.ts | 103 +++++++++ .../backend/DesktopBackendConfiguration.ts | 25 ++- apps/server/src/bin.test.ts | 1 + apps/server/src/cli/config.test.ts | 44 ++++ apps/server/src/cli/config.ts | 8 +- apps/server/src/cli/pair.ts | 1 + apps/server/src/config.ts | 14 ++ .../src/environment/ServerEnvironment.test.ts | 1 + .../src/observability/Layers/Observability.ts | 17 +- apps/server/src/server.test.ts | 4 + apps/server/src/serverLogger.test.ts | 197 ++++++++++++++++++ apps/server/src/serverLogger.ts | 42 +++- apps/server/src/serverSettings.test.ts | 2 + apps/server/src/ws.ts | 2 + docs/operations/observability.md | 19 +- .../client-runtime/src/rpc/session.test.ts | 1 + packages/contracts/src/desktopBootstrap.ts | 1 + packages/contracts/src/server.test.ts | 17 ++ packages/contracts/src/server.ts | 4 + packages/contracts/src/settings.ts | 2 + packages/shared/src/serverSettings.test.ts | 9 +- packages/shared/src/serverSettings.ts | 5 +- 25 files changed, 503 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/serverLogger.test.ts diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index d03dec70ce82..773ea24509cf 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -46,6 +46,8 @@ export const DesktopConfig = Config.all({ desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), + otlpMetricsUrl: trimmedString("T3CODE_OTLP_METRICS_URL"), + otlpLogsUrl: trimmedString("T3CODE_OTLP_LOGS_URL"), otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 0e5fbecd0224..b5aabf253d0f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -51,6 +51,8 @@ describe("DesktopEnvironment", () => { VITE_DEV_SERVER_URL: "http://localhost:5173", T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", + T3CODE_OTLP_METRICS_URL: " http://127.0.0.1:4318/v1/metrics ", + T3CODE_OTLP_LOGS_URL: " http://127.0.0.1:4318/v1/logs ", T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", T3CODE_OTLP_PROTOCOL: "http/protobuf", @@ -86,6 +88,8 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); + assert.deepEqual(environment.otlpMetricsUrl, Option.some("http://127.0.0.1:4318/v1/metrics")); + assert.deepEqual(environment.otlpLogsUrl, Option.some("http://127.0.0.1:4318/v1/logs")); assert.equal(environment.otlpExportIntervalMs, 2500); assert.deepEqual( environment.otlpHeaders, diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index e7a489d5e89d..d6924b08acec 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -72,6 +72,8 @@ export class DesktopEnvironment extends Context.Service< readonly configuredBackendPort: Option.Option; readonly commitHashOverride: Option.Option; readonly otlpTracesUrl: Option.Option; + readonly otlpMetricsUrl: Option.Option; + readonly otlpLogsUrl: Option.Option; readonly otlpExportIntervalMs: number; readonly otlpHeaders: Option.Option>; readonly otlpProtocol: OtlpProtocol; @@ -227,6 +229,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( configuredBackendPort: config.configuredBackendPort, commitHashOverride: config.commitHashOverride, otlpTracesUrl: config.otlpTracesUrl, + otlpMetricsUrl: config.otlpMetricsUrl, + otlpLogsUrl: config.otlpLogsUrl, otlpExportIntervalMs: config.otlpExportIntervalMs, otlpHeaders: config.otlpHeaders, otlpProtocol: config.otlpProtocol, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 595582a16280..41eeedc668b6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -23,6 +23,7 @@ const PersistedServerObservabilitySettingsDocument = Schema.Struct({ observability: Schema.Struct({ otlpTracesUrl: Schema.String, otlpMetricsUrl: Schema.String, + otlpLogsUrl: Schema.String, }), }); @@ -60,6 +61,9 @@ function makeEnvironmentLayer( readonly resourcesPath?: string; readonly appVersion?: string; readonly processArch?: NodeJS.Architecture; + readonly otlpTracesUrl?: string; + readonly otlpMetricsUrl?: string; + readonly otlpLogsUrl?: string; }, ) { return DesktopEnvironment.layer({ @@ -82,6 +86,9 @@ function makeEnvironmentLayer( T3CODE_MODE: "desktop", T3CODE_DESKTOP_LAN_HOST: "192.168.1.50", VITE_DEV_SERVER_URL: options?.devServerUrl, + T3CODE_OTLP_TRACES_URL: options?.otlpTracesUrl, + T3CODE_OTLP_METRICS_URL: options?.otlpMetricsUrl, + T3CODE_OTLP_LOGS_URL: options?.otlpLogsUrl, }), ), ), @@ -730,6 +737,7 @@ describe("DesktopBackendConfiguration", () => { observability: { otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", + otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", }, }), ); @@ -737,6 +745,7 @@ describe("DesktopBackendConfiguration", () => { const config = yield* configuration.resolvePrimary; assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); + assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); }), ), ); @@ -749,10 +758,103 @@ describe("DesktopBackendConfiguration", () => { assert.isUndefined(config.bootstrap.otlpTracesUrl); assert.isUndefined(config.bootstrap.otlpMetricsUrl); + assert.isUndefined(config.bootstrap.otlpLogsUrl); }), ), ); + it.effect("resolveWsl carries environment-configured observability endpoints", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + // No settings.json exists here: the endpoints come from the desktop + // process's env, which a WSL child cannot inherit, so the bootstrap + // has to carry them or log export stays off inside the distro. + assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); + assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); + assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); + assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + platform: "win32", + otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", + otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", + otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("environment observability endpoints win over the persisted settings file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { + recursive: true, + }); + yield* fileSystem.writeFileString( + environment.serverSettingsPath, + yield* encodePersistedServerObservabilitySettingsDocument({ + observability: { + otlpTracesUrl: "http://persisted:4318/v1/traces", + otlpMetricsUrl: "http://persisted:4318/v1/metrics", + otlpLogsUrl: "http://persisted:4318/v1/logs", + }, + }), + ); + + const config = yield* configuration.resolvePrimary; + assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs"); + // Only the logs endpoint is set in env, so the other two still come + // from the settings file rather than being dropped together. + assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces"); + assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { otlpLogsUrl: "http://env:4318/v1/logs" }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("logs structured context when persisted observability settings cannot be read", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -799,6 +901,7 @@ describe("DesktopBackendConfiguration", () => { assert.isUndefined(config.bootstrap.otlpTracesUrl); assert.isUndefined(config.bootstrap.otlpMetricsUrl); + assert.isUndefined(config.bootstrap.otlpLogsUrl); const error = messages .flatMap((message) => (Array.isArray(message) ? message : [message])) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index a36902304809..9e809667ac42 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -67,11 +67,13 @@ export class DesktopBackendConfiguration extends Context.Service< interface BackendObservabilitySettings { readonly otlpTracesUrl: Option.Option; readonly otlpMetricsUrl: Option.Option; + readonly otlpLogsUrl: Option.Option; } const emptyBackendObservabilitySettings: BackendObservabilitySettings = { otlpTracesUrl: Option.none(), otlpMetricsUrl: Option.none(), + otlpLogsUrl: Option.none(), }; const DESKTOP_BACKEND_ENV_NAMES = [ @@ -212,9 +214,26 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { return { otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), + otlpLogsUrl: Option.fromNullishOr(parsed.otlpLogsUrl), }; }); +// The bootstrap is the only channel that carries an OTLP endpoint to every +// backend. A Windows-native child inherits the desktop process's env, but a +// WSL child gets nothing across wsl.exe that WSLENV does not declare, and +// WSLENV translation of URL-shaped values is unreliable, so the endpoints are +// deliberately not forwarded that way. Env beats the persisted settings file, +// matching the precedence resolveServerConfig and DesktopObservability apply. +const readBackendObservabilitySettings = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const persisted = yield* readPersistedBackendObservabilitySettings; + return { + otlpTracesUrl: Option.orElse(environment.otlpTracesUrl, () => persisted.otlpTracesUrl), + otlpMetricsUrl: Option.orElse(environment.otlpMetricsUrl, () => persisted.otlpMetricsUrl), + otlpLogsUrl: Option.orElse(environment.otlpLogsUrl, () => persisted.otlpLogsUrl), + } satisfies BackendObservabilitySettings; +}); + interface SharedBootstrapInput { readonly bootstrapToken: string; readonly observabilitySettings: BackendObservabilitySettings; @@ -488,6 +507,10 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS onNone: () => ({}), onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), }), + ...Option.match(observabilitySettings.otlpLogsUrl, { + onNone: () => ({}), + onSome: (otlpLogsUrl) => ({ otlpLogsUrl }), + }), }); const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")( @@ -804,7 +827,7 @@ export const make = Effect.gen(function* () { // restart cycle without having to bounce the desktop process. const sharedInputs = Effect.gen(function* () { const bootstrapToken = yield* getOrCreateBootstrapToken; - const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + const observabilitySettings = yield* readBackendObservabilitySettings.pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index c1bea133f718..795bb05db148 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -100,6 +100,7 @@ const makeCliTestServerConfig = (baseDir: string) => traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 8c4dbbcdbbf2..aea753c3398f 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -50,6 +50,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, @@ -398,6 +399,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServePort: 443, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }), ); const derivedPaths = yield* deriveServerPaths(baseDir, undefined); @@ -438,6 +440,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ...defaultObservabilityConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", mode: "desktop", port: 4888, cwd: process.cwd(), @@ -604,6 +607,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }, })}\n`, ); @@ -635,11 +639,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); + expect(resolved.otlpLogsUrl).toBe("http://localhost:4318/v1/logs"); expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", mode: "desktop", port: 4888, cwd: process.cwd(), @@ -845,4 +851,42 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpProtocol).toBe("http/protobuf"); }), ); + + it.effect("reads the OTLP logs URL from env", () => + Effect.gen(function* () { + const { join } = yield* Path.Path; + const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-logs-url-base"); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("web"), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_OTLP_LOGS_URL: "http://collector.internal:4318/v1/logs" }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 6db6abbcc8a1..32f465b6fb0a 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -96,6 +96,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + otlpLogsUrl: Config.String("T3CODE_OTLP_LOGS_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), @@ -227,7 +231,7 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st const fs = yield* FileSystem.FileSystem; const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); if (!exists) { - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; } const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); @@ -393,6 +397,8 @@ export const resolveServerConfig = ( env.otlpMetricsUrl ?? bootstrap?.otlpMetricsUrl ?? persistedObservabilitySettings.otlpMetricsUrl, + otlpLogsUrl: + env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, otlpExportIntervalMs: env.otlpExportIntervalMs, otlpServiceName: env.otlpServiceName, otlpHeaders: env.otlpHeaders, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 94c88b294770..9ff9a8e13b39 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -320,6 +320,7 @@ const makePairServerConfig = Effect.fn(function* (input: { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 919628c8d1ed..4835ebb40e44 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service< readonly traceMaxFiles: number; readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; + readonly otlpLogsUrl: string | undefined; readonly otlpExportIntervalMs: number; readonly otlpServiceName: string; readonly otlpHeaders: Readonly> | undefined; @@ -106,6 +107,18 @@ export class ServerConfig extends Context.Service< export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config); +/** + * Resource attributes shared by every OTLP exporter, so traces, metrics, and + * logs report the same service identity to the collector. + */ +export const otlpResource = (config: ServerConfig["Service"]) => ({ + serviceName: config.otlpServiceName, + attributes: { + "service.runtime": "t3-server", + "service.mode": config.mode, + }, +}); + export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerConfig, make(config)); export const deriveServerPaths = Effect.fn(function* ( @@ -198,6 +211,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index b8cb84349472..0ccf8de691eb 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -53,6 +53,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 7a7aaf910a9b..0c9acdfb1ee3 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -21,6 +21,7 @@ export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const serializationLayer = otlpSerializationLayer(config.otlpProtocol); + const resource = ServerConfig.otlpResource(config); const attribution = yield* ResourceAttribution.ResourceAttribution; const traceReferencesLayer = Layer.mergeAll( @@ -52,13 +53,7 @@ export const ObservabilityLive = Layer.unwrap( url: config.otlpTracesUrl, exportInterval: `${config.otlpExportIntervalMs} millis`, headers: config.otlpHeaders, - resource: { - serviceName: config.otlpServiceName, - attributes: { - "service.runtime": "t3-server", - "service.mode": config.mode, - }, - }, + resource, }); const tracer = yield* makeLocalFileTracer({ @@ -84,13 +79,7 @@ export const ObservabilityLive = Layer.unwrap( url: config.otlpMetricsUrl, exportInterval: `${config.otlpExportIntervalMs} millis`, headers: config.otlpHeaders, - resource: { - serviceName: config.otlpServiceName, - attributes: { - "service.runtime": "t3-server", - "service.mode": config.mode, - }, - }, + resource, }).pipe(Layer.provideMerge(serializationLayer)); return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cb2cf03659ef..512e55a64cbb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -580,6 +580,7 @@ const buildAppUnderTest = (options?: { traceMaxFiles: 10, otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", otlpHeaders: undefined, @@ -6291,6 +6292,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { config: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }, layers: { keybindings: { @@ -6326,6 +6328,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(first.config.observability.otlpTracesEnabled, true); assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); assert.equal(first.config.observability.otlpMetricsEnabled, true); + assert.equal(first.config.observability.otlpLogsUrl, "http://localhost:4318/v1/logs"); + assert.equal(first.config.observability.otlpLogsEnabled, true); assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS); } assert.deepEqual(second, { diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts new file mode 100644 index 000000000000..59ca908cb4ad --- /dev/null +++ b/apps/server/src/serverLogger.test.ts @@ -0,0 +1,197 @@ +import * as NodePath from "@effect/platform-node/NodePath"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Tracer from "effect/Tracer"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as ServerConfig from "./config.ts"; +import { ServerLoggerLive } from "./serverLogger.ts"; + +interface ExportedRequest { + readonly url: string; + readonly headers: Readonly>; + readonly body: string; +} + +/** Answers every export with a 200 and keeps what was posted for assertions. */ +const collectorLayer = (requests: Array) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push({ + url: request.url, + headers: request.headers, + body: + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "", + }); + return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })); + }), + ), + ); + +const configLayer = (overrides: Partial) => + Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const path = yield* Path.Path; + const baseDir = path.join(NodeOS.tmpdir(), "t3-server-logger-test"); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + return ServerConfig.make({ + logLevel: "Info", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 1024, + traceMaxFiles: 1, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + otlpHeaders: undefined, + otlpProtocol: "http/json", + cwd: baseDir, + baseDir, + ...derivedPaths, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + port: 0, + host: undefined, + desktopBootstrapToken: undefined, + desktopTelemetryFd: undefined, + desktopTelemetryControlFd: undefined, + resourceMonitorPath: undefined, + staticDir: undefined, + devUrl: undefined, + devAllowedOrigins: [], + noBrowser: false, + startupPresentation: "browser", + ...overrides, + }); + }), + ).pipe(Layer.provide(NodePath.layer)); + +/** + * Logs once with the server's own logger set installed, then reports what the + * collector received. The export is asserted after the layer's scope closes, + * which is where the exporter flushes whatever the interval did not. + */ +const logThrough = (overrides: Partial) => + Effect.gen(function* () { + const requests: Array = []; + yield* Effect.log("server logger under test").pipe( + Effect.provide( + ServerLoggerLive.pipe( + Layer.provide(configLayer(overrides)), + Layer.provide(collectorLayer(requests)), + ), + ), + ); + return requests; + }); + +/** + * Logs inside a span so the tracer logger has somewhere to attach an event, + * then reports both what the collector received and what landed on the span. + */ +const logInSpanThrough = (overrides: Partial) => + Effect.gen(function* () { + const requests: Array = []; + const spans: Array = []; + const tracerLayer = Layer.succeed( + Tracer.Tracer, + Tracer.make({ + span: (spanOptions) => { + const span = new Tracer.NativeSpan(spanOptions); + spans.push(span); + return span; + }, + }), + ); + yield* Effect.log("server logger under test").pipe( + Effect.withSpan("server-logger-test"), + Effect.provide( + Layer.mergeAll( + ServerLoggerLive.pipe( + Layer.provide(configLayer(overrides)), + Layer.provide(collectorLayer(requests)), + ), + tracerLayer, + ), + ), + ); + return { requests, spans }; + }); + +describe("ServerLoggerLive", () => { + it.effect("exports log records to the configured logs endpoint", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + }); + + assert.lengthOf(requests, 1); + const [request] = requests; + assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); + assert.include(request?.body ?? "", "server logger under test"); + assert.include(request?.body ?? "", "t3-server"); + assert.include(request?.body ?? "", "service.runtime"); + }), + ); + + it.effect("stays off the network when no logs endpoint is configured", () => + Effect.gen(function* () { + const requests = yield* logThrough({}); + + assert.lengthOf(requests, 0); + }), + ); + + it.effect("sends the headers and wire format the rest of OTLP export already uses", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + otlpProtocol: "http/protobuf", + otlpHeaders: { "x-scope": "logs" }, + }); + + assert.lengthOf(requests, 1); + assert.strictEqual(requests[0]?.headers["x-scope"], "logs"); + assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf"); + }), + ); + + it.effect("attaches log messages to the active span when no logs endpoint is configured", () => + Effect.gen(function* () { + const { requests, spans } = yield* logInSpanThrough({}); + + assert.lengthOf(requests, 0); + assert.lengthOf(spans, 1); + assert.deepEqual( + spans[0]?.events.map(([name]) => name), + ["server logger under test"], + ); + }), + ); + + it.effect("stops duplicating messages onto the span once log records are exported", () => + Effect.gen(function* () { + const { requests, spans } = yield* logInSpanThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + }); + + assert.lengthOf(requests, 1); + assert.include(requests[0]?.body ?? "", "server logger under test"); + assert.lengthOf(spans, 1); + assert.lengthOf(spans[0]?.events ?? [], 0); + }), + ); +}); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index a7cb1d6a26ee..389a9535efab 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,16 +1,48 @@ +import { otlpSerializationLayer } from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as References from "effect/References"; -import * as Layer from "effect/Layer"; +import * as OtlpExporter from "effect/unstable/observability/OtlpExporter"; +import * as OtlpLogger from "effect/unstable/observability/OtlpLogger"; -import { ServerConfig } from "./config.ts"; +import { otlpResource, ServerConfig } from "./config.ts"; export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel); - const loggerLayer = Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { - mergeWithExisting: false, - }); + + const otlpLogger = + config.otlpLogsUrl === undefined + ? undefined + : OtlpLogger.make({ + url: config.otlpLogsUrl, + exportInterval: `${config.otlpExportIntervalMs} millis`, + headers: config.otlpHeaders, + resource: otlpResource(config), + }); + + // `Logger.layer` writes the whole logger set rather than adding to it, so + // every logger the server wants has to be named in this one call. + // + // `Logger.tracerLogger` reaches a collector by attaching each message to the + // active span as a span event, which covers only messages logged inside a + // recorded span and files them under traces. The OTLP logger carries the same + // messages as log records stamped with their trace and span ids, so it is a + // superset: keeping both would export every in-span message twice. + // + // Recording events on spans is also the shape OpenTelemetry is deprecating, + // in favor of the log-based events this logger emits: + // https://opentelemetry.io/blog/2026/deprecating-span-events/ + const loggerLayer = Logger.layer( + otlpLogger === undefined + ? [Logger.consolePretty(), Logger.tracerLogger] + : [Logger.consolePretty(), otlpLogger], + { mergeWithExisting: false }, + ).pipe( + Layer.provide(OtlpExporter.layerFlusher), + Layer.provide(otlpSerializationLayer(config.otlpProtocol)), + ); return Layer.mergeAll(loggerLayer, minimumLogLevelLayer); }).pipe(Layer.unwrap); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index bd216a0e88a1..b2caba4a0347 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -969,6 +969,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", otlpMetricsUrl: " http://localhost:4318/v1/metrics ", + otlpLogsUrl: " http://localhost:4318/v1/logs ", }, }); @@ -976,6 +977,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(next.observability, { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0b9bd1136bf3..d118bd3d012c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1808,6 +1808,8 @@ const makeWsRpcLayer = ( ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}), + otlpLogsEnabled: config.otlpLogsUrl !== undefined, }, settings, shellResumeCompletionMarker: true, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 5668ca9d6fbb..215b1706aeef 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -6,7 +6,7 @@ T3 Code has one server-side observability model: - pretty logs go to stdout for humans - completed spans go to a local NDJSON trace file -- traces and metrics can also be exported over OTLP to a real backend like Grafana LGTM +- traces, metrics, and logs can also be exported over OTLP to a real backend like Grafana LGTM The local trace file is the persisted source of truth for normal local launches. Those launches do not write a separate server log file, but SSH-managed launches also persist the remote process's @@ -22,9 +22,16 @@ Logs are human-facing: - format: `Logger.consolePretty()` - normal local persistence: none - SSH-managed launch persistence: `~/.t3/ssh-launch//server.log` +- remote export: OTLP only, when configured If you want a log message to show up in the trace file, emit it inside an active span with `Effect.log...`. `Logger.tracerLogger` will attach it as a span event. +Configuring a logs endpoint takes over that job. The server then exports log records, which cover +every message instead of only the ones inside an active span and carry the trace and span ids so +they still line up with the trace. `Logger.tracerLogger` is dropped in that mode, so the same +message is not exported twice and the trace file stops carrying log messages. stdout output and +SSH-managed launch persistence stay unchanged either way. + ### Traces Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the @@ -121,6 +128,7 @@ Default Grafana login: ```bash export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics +export T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs export T3CODE_OTLP_SERVICE_NAME=t3-local ``` @@ -160,6 +168,7 @@ macOS app bundle example: ```bash T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ +T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \ T3CODE_OTLP_SERVICE_NAME=t3-desktop \ "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/Applications/T3 Code.app/Contents/MacOS/T3 Code" ``` @@ -169,6 +178,7 @@ Direct binary example: ```bash T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \ T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \ +T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \ T3CODE_OTLP_SERVICE_NAME=t3-desktop \ ./path/to/your/desktop-app-binary ``` @@ -509,6 +519,7 @@ It provides: - local NDJSON tracer - optional OTLP trace exporter - optional OTLP metrics exporter +- optional OTLP log exporter - Effect trace-level and timing refs ### Env Vars @@ -526,13 +537,15 @@ OTLP export: - `T3CODE_OTLP_TRACES_URL`: OTLP trace endpoint - `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint +- `T3CODE_OTLP_LOGS_URL`: OTLP log endpoint - `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000` - `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server` -- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as +- `T3CODE_OTLP_HEADERS`: extra headers for all three exporters, same format as `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` -If the OTLP URLs are unset, local tracing still works and metrics stay in-process only. +If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay +on stdout only. ### What Is Instrumented Today diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 4687b84c359f..65b537921f09 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -149,6 +149,7 @@ const SERVER_CONFIG: ServerConfigType = { localTracingEnabled: false, otlpTracesEnabled: false, otlpMetricsEnabled: false, + otlpLogsEnabled: false, }, settings: DEFAULT_SERVER_SETTINGS, }; diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index f4d1a0927861..bc3558aa2962 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -16,6 +16,7 @@ export const DesktopBackendBootstrap = Schema.Struct({ tailscaleServePort: PortSchema, otlpTracesUrl: Schema.optional(Schema.String), otlpMetricsUrl: Schema.optional(Schema.String), + otlpLogsUrl: Schema.optional(Schema.String), desktopTelemetryFd: Schema.optionalKey(PositiveInt), desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 0a7e342ab752..9112896eb1c9 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -5,6 +5,7 @@ import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { resolveEnvironmentMachineKind, ServerConfig, + ServerObservability, ServerProvider, ServerProviders, ServerUpsertKeybindingResult, @@ -13,6 +14,7 @@ import { ServerSettings } from "./settings.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); +const decodeServerObservability = Schema.decodeUnknownSync(ServerObservability); const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); @@ -177,6 +179,21 @@ describe("server config forward compatibility", () => { }); }); +describe("ServerObservability", () => { + it("reads a server from before the log signal as exporting no logs", () => { + const parsed = decodeServerObservability({ + logsDirectoryPath: "/tmp/t3/logs", + localTracingEnabled: true, + otlpTracesUrl: "https://collector.example.com/v1/traces", + otlpTracesEnabled: true, + otlpMetricsEnabled: false, + }); + + expect(parsed.otlpLogsEnabled).toBe(false); + expect(parsed.otlpLogsUrl).toBeUndefined(); + }); +}); + describe("resolveEnvironmentMachineKind", () => { const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor); const decodeSettings = Schema.decodeUnknownSync(ServerSettings); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8073b3f4ed0a..49a377208799 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -261,6 +261,10 @@ export const ServerObservability = Schema.Struct({ otlpTracesEnabled: Schema.Boolean, otlpMetricsUrl: Schema.optional(TrimmedNonEmptyString), otlpMetricsEnabled: Schema.Boolean, + otlpLogsUrl: Schema.optional(TrimmedNonEmptyString), + // Absent on servers from before the log signal shipped, so a newer client + // reads those as having no log export rather than rejecting the whole config. + otlpLogsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), }); export type ServerObservability = typeof ServerObservability.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9de1f5ebb8dc..42e91a76ecaf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -889,6 +889,7 @@ export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + otlpLogsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; @@ -1481,6 +1482,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ otlpTracesUrl: Schema.optionalKey(TrimmedString), otlpMetricsUrl: Schema.optionalKey(TrimmedString), + otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), providers: Schema.optionalKey( diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 267e7a7b3442..05f625a1bd0f 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -224,14 +224,18 @@ describe("serverSettings helpers", () => { expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, }); expect( parsePersistedServerObservabilitySettings( - JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }), + JSON.stringify({ + observability: { otlpTracesUrl: " ", otlpMetricsUrl: "", otlpLogsUrl: " " }, + }), ), ).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, }); }); @@ -242,12 +246,14 @@ describe("serverSettings helpers", () => { observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", otlpMetricsUrl: " http://localhost:4318/v1/metrics ", + otlpLogsUrl: " http://localhost:4318/v1/logs ", }, }), ), ).toEqual({ otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", }); }); @@ -255,6 +261,7 @@ describe("serverSettings helpers", () => { expect(parsePersistedServerObservabilitySettings("{")).toEqual({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 61103b77babd..d847cb2b0795 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -102,6 +102,7 @@ export function resolveSourceControlWriterModelSelection( export interface PersistedServerObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; + readonly otlpLogsUrl: string | undefined; } function normalizePersistedServerSettingString( @@ -115,11 +116,13 @@ function extractPersistedServerObservabilitySettings(input: { readonly observability?: { readonly otlpTracesUrl?: string; readonly otlpMetricsUrl?: string; + readonly otlpLogsUrl?: string; }; }): PersistedServerObservabilitySettings { return { otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl), otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl), + otlpLogsUrl: normalizePersistedServerSettingString(input.observability?.otlpLogsUrl), }; } @@ -130,7 +133,7 @@ export function parsePersistedServerObservabilitySettings( if (Option.isSome(decoded)) { return extractPersistedServerObservabilitySettings(decoded.value); } - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined }; } function shouldReplaceTextGenerationModelSelection( From ea15f17afd94e7ce7133e52f5b5983910662c9ec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 16:09:00 -0700 Subject: [PATCH 032/111] fix(mobile): use native settings and snooze controls (#12512) --- .../sections/CodeAppearanceSection.tsx | 2 +- .../sections/TerminalAppearanceSection.tsx | 2 +- .../sections/TextAppearanceSection.tsx | 2 +- .../settings/components/SettingsSection.tsx | 6 +- .../threads/CustomSnoozeSheet.android.tsx | 12 +- .../threads/CustomSnoozeSheet.ios.tsx | 296 ++++++++++-------- .../threads/CustomSnoozeSheet.shared.tsx | 4 +- .../features/threads/ThreadSettingsSheet.tsx | 196 ++++++------ .../threads/provider-catalog-refresh.ts | 2 +- .../src/features/usage/UsageRouteScreen.tsx | 6 +- 10 files changed, 284 insertions(+), 244 deletions(-) diff --git a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx index bb4bb4d0a8fe..6760504bfa28 100644 --- a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx @@ -26,7 +26,7 @@ export function CodeAppearanceSection() { ); return ( - + + + @@ -30,9 +28,7 @@ export function SettingsSection(props: { className={ Platform.OS === "android" ? "overflow-hidden rounded-[28px] bg-card" - : props.card - ? "overflow-hidden rounded-[24px] border-continuous bg-card" - : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent" + : "overflow-hidden rounded-[24px] border-continuous bg-card" } > {props.children} diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx index 85600838881f..e066c01b43cf 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx @@ -1,7 +1,6 @@ import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android"; import { BasicAlertDialog, - Button, Column, DateTimePicker, Host, @@ -240,19 +239,16 @@ export function CustomSnoozeSheet(props: Props) { Cancel - + diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index 20176064be52..c8d9abbc5fa8 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -1,27 +1,22 @@ import { - Button, DatePicker, Host, HStack, Picker, Popover, + RNHostView, Spacer, Text, VStack, } from "@expo/ui/swift-ui"; import { - accessibilityAddTraits, accessibilityHidden, - buttonBorderShape, - buttonStyle, clipped, - controlSize, font, datePickerStyle, foregroundStyle, frame, labelsHidden, - labelStyle, padding, pickerStyle, tag, @@ -32,12 +27,17 @@ import { resolveCustomSnooze, type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; -import { useState } from "react"; -import { useWindowDimensions } from "react-native"; +import { useState, type ReactNode } from "react"; +import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { ScrollView, useWindowDimensions, View } from "react-native"; +import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); +const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>(); const modes = [ { value: "date", label: "Date and time" }, { value: "duration", label: "Duration" }, @@ -52,13 +52,15 @@ export function CustomSnoozeSheet(props: { readonly onClose: () => void; readonly onSnooze: (snoozedUntil: string) => void; }) { - const { width } = useWindowDimensions(); + const { width, height } = useWindowDimensions(); const [mode, setMode] = useState("date"); const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); const [amount, setAmount] = useState(2); const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); const [error, setError] = useState(null); const { themeVariables: colors, themeAppearance } = useAppearancePreferences(); + const popoverWidth = Math.min(360, width - 32); + const popoverHeight = Math.min(error ? 364 : 324, height - 96); const updateDate = (value: Date) => { setDate(value); setError(null); @@ -98,133 +100,179 @@ export function CustomSnoozeSheet(props: { /> - - -