From 6bc87b94e15cf93c5448cc8f1dc52e7cd2271b82 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 05:41:03 -0700 Subject: [PATCH 01/13] ci(release): desktop builds stop hitting the npm registry for staged installs (#26458) Co-authored-by: Claude Opus 5.5 --- .github/workflows/release-desktop.yml | 48 ++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 359070e5482fb..ba40c4fb89cba 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -163,6 +163,29 @@ jobs: env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + # The CLI archive and the staged app install without a lockfile, so they + # ask the registry about every package and fetch platform packages the + # workspace store lacks. They share this registry metadata and store. A + # new week starts cold, which bounds how stale cached ranges can get. + - name: Resolve staged install cache + id: stage_pnpm + shell: bash + run: | + echo "dir=$RUNNER_TEMP/desktop-stage-pnpm" >> "$GITHUB_OUTPUT" + echo "prefix=desktop-stage-pnpm-v2-${{ inputs.platform }}-${{ inputs.arch }}-$(date -u +%G-%V)-" >> "$GITHUB_OUTPUT" + + - name: Restore staged install cache + id: stage_pnpm_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.stage_pnpm.outputs.prefix }} + + - name: Mark staged install cache + shell: bash + run: mkdir -p "${{ steps.stage_pnpm.outputs.dir }}" && touch "$RUNNER_TEMP/desktop-stage-pnpm.marker" + - name: Cache resource monitor id: resource_monitor_cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 @@ -371,6 +394,9 @@ jobs: if: inputs.cli_archive shell: bash env: + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} @@ -490,7 +516,10 @@ jobs: - name: Build desktop artifact shell: bash env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + # pnpm only goes to the registry for packages or versions the cache lacks. + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} @@ -575,6 +604,23 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # Warm installs only touch the store index, so new content means a fetch. + - name: Check staged install cache for new content + id: stage_pnpm_changed + shell: bash + run: | + dir="${{ steps.stage_pnpm.outputs.dir }}" + new=$(find "$dir/cache/v12" "$dir/store/v11/files" -type f -newer "$RUNNER_TEMP/desktop-stage-pnpm.marker" 2>/dev/null | wc -l | tr -d ' ') + echo "Staged installs added $new cached files." + echo "changed=$([[ "$new" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Save staged install cache + if: steps.stage_pnpm_changed.outputs.changed == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }} + # A build can package fine and still not open (a missing module in the # backend or the bundle). This launches the app from the shipped # AppImage or DMG. From 5211acfe95a0c6d82a60e2524b5e46b47fe67a3e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 06:02:48 -0700 Subject: [PATCH 02/13] feat(web): boring sidebar can show folder threads on two lines (#26463) Co-authored-by: Claude Opus 5.5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/Sidebar.tsx | 9 +- .../components/settings/LabsSettingsPanel.tsx | 21 + .../src/components/settings/settingsSearch.ts | 6 + .../components/sidebar/SidebarThreadRow.tsx | 390 +++++++++--------- packages/contracts/src/settings.ts | 4 + 6 files changed, 239 insertions(+), 192 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1a927643be628..ea351b6ceb2f4 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -63,6 +63,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, + boringSidebarMultilineRows: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0868554b50ffd..c4ba300a030c3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -962,6 +962,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, }); const boringSidebarEnabled = useBoringSidebarEnabled(); + const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. const { handleNewThread } = newThreadContext; @@ -3168,7 +3169,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); const isCard = section === "active" || section === "pinned" || section === "working"; - const rowVariant = folderRow ? "folder" : isCard ? "card" : "slim"; + const rowVariant = folderRow + ? folderRowsMultiline + ? "folder-multiline" + : "folder" + : isCard + ? "card" + : "slim"; return ( + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -47,6 +48,26 @@ function BoringSidebarRow() { ); } +function BoringSidebarMultilineRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ boringSidebarMultilineRows: Boolean(checked) }) + } + aria-label="Two-line folder rows" + /> + } + /> + ); +} + /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index fe4faa61996e4..325d0c1ba75e2 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -431,6 +431,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["beta folders project tree legacy old classic sidebar"], }, + { + id: "boring-sidebar-multiline", + title: "Two-line folder rows", + to: "/settings/labs", + searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], + }, { id: "guest-links", title: "Guest links", diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index eac7c8591fa1c..a111126342f4e 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -533,8 +533,9 @@ function TopStatusIcon(props: { icon: keyof typeof TOP_STATUS_ICONS; className: type SidebarThreadRowProps = { thread: SidebarThreadSummary; - // "folder" is a one-line row inside a boring sidebar project folder. - variant: "card" | "slim" | "folder"; + // "folder" is a one-line row inside a boring sidebar project folder; + // "folder-multiline" adds the card's branch and PR line under it. + variant: "card" | "slim" | "folder" | "folder-multiline"; navigationParent?: string | undefined; variantAction: SidebarSweepAction; settlementSupported: boolean; @@ -1121,8 +1122,76 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) ) : null; + const diff = latestRunDiff(thread); + // Only the provider icon would remain, so the row drops the line instead of leaving it blank. + const metaLineEmpty = + !thread.branch && + !showsScratchMachine && + prBadge === null && + diff === null && + !isRemote && + runningTerminalIds.length === 0; + const metaLine = metaLineEmpty ? null : ( +
+ {thread.branch ? ( + <> + + + + + + ) : showsScratchMachine ? ( + <> + + {props.scratchMachineLabel} + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{formatDiffCount(diff.insertions)}{" "} + −{formatDiffCount(diff.deletions)} + + ) : null} + + {isRemote && !showsScratchMachine ? ( + + + + ) : null} + + +
+ ); + const titleLineProviderStack = metaLineEmpty ? ( + + + + ) : null; + if (variant !== "card") { - const inFolder = variant === "folder"; + const multiline = variant === "folder-multiline"; + const inFolder = variant === "folder" || multiline; return (
  • @@ -1148,7 +1218,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP aria-busy={isRegeneratingTitle || undefined} className={cn( rowSurfaceClassName, - "flex h-9 items-center gap-2.5 px-2.5", + multiline + ? "flex flex-col justify-center px-2.5 py-1.5" + : "flex h-9 items-center gap-2.5 px-2.5", // Lines titles up with the folder name, past its chevron and icon. inFolder && "ps-9", )} @@ -1159,142 +1231,147 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP /> } > - {accessibleTitle} - {inFolder ? null : ( - - {props.project ? ( - - ) : null} - - )} - {draftIndicator} - {title} - {pinIndicator} - {terminalStatusIcon} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {sortable?.isDragging ? ( - dragDestination - ) : ( - + {/* `contents` keeps one-line rows a single flex row. */} +
    + {accessibleTitle} + {inFolder ? null : ( - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - - {/* The clock tells a wake time from an age at a glance. */} - - {props.snoozeWakeLabelText} - - ) : (inFolder ? isWokeStatus : isWoke) ? ( + {props.project ? ( + + ) : null} + + )} + {draftIndicator} + {title} + {multiline ? titleLineProviderStack : null} + {pinIndicator} + {multiline ? null : terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {sortable?.isDragging ? ( + dragDestination + ) : ( + + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + + {/* The clock tells a wake time from an age at a glance. */} + + {props.snoozeWakeLabelText} + + ) : (inFolder ? isWokeStatus : isWoke) ? ( + + + + Woke + + } + /> + Dismiss Woke notification + + ) : inFolder && topStatus ? ( + // Folder rows have no status line, so the status takes the time slot. + + + {topStatus.label} + + ) : ( + + + + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - Woke - + aria-label="Un-settle thread" + onClick={handleUnsettleClick} + onPointerDown={handleActionPointerDown} + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWoke && "group-any-hover/sidebar-row:static", + )} + /> } - /> - Dismiss Woke notification + > + + + Un-settle thread - ) : inFolder && topStatus ? ( - // Folder rows have no status line, so the status takes the time slot. - - - {topStatus.label} - ) : ( - - - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - - )} - {props.sweepAction !== null ? dragDestination : null} + )} + + )} + {props.sweepAction !== null ? dragDestination : null} +
    + {multiline ? metaLine : null} {props.jumpLabel ? : null} {detailsTooltip} @@ -1303,16 +1380,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); } - const diff = latestRunDiff(thread); - // Only the provider icon would remain, so the row drops the line instead of leaving it blank. - const metaLineEmpty = - !thread.branch && - !showsScratchMachine && - prBadge === null && - diff === null && - !isRemote && - runningTerminalIds.length === 0; - return (
  • ) : null} - {metaLineEmpty ? ( - - - - ) : null} + {titleLineProviderStack} - {metaLineEmpty ? null : ( -
    - {thread.branch ? ( - <> - - - - - - ) : showsScratchMachine ? ( - <> - - {props.scratchMachineLabel} - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - - +{formatDiffCount(diff.insertions)} - {" "} - - −{formatDiffCount(diff.deletions)} - - - ) : null} - - {isRemote && !showsScratchMachine ? ( - - - - ) : null} - - -
    - )} + {metaLine} {props.jumpLabel ? : null} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 48ddc9d43b5b5..c6a59445d66fa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -474,6 +474,9 @@ export const ClientSettingsSchema = Schema.Struct({ proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), boringSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + boringSidebarMultilineRows: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), // Opt-in for the removed legacy sidebar. Still read so those users land in // the boring sidebar; the boring sidebar toggle clears it. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1707,6 +1710,7 @@ export const ClientSettingsPatch = Schema.Struct({ proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), + boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), From 125b61954dbba5822a8108c8ca47a4846b4b4328 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 09:27:59 -0700 Subject: [PATCH 03/13] fix(server): release completed pull request sweep data (#25177) --- .../PullRequestSyncReactor.test.ts | 38 +++++++++++++++++++ .../PullRequestSyncReactor.ts | 13 +++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 028e74b06a2ca..eccb107bc2516 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1,3 +1,5 @@ +import * as NodeV8 from "node:v8"; + import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Stream from "effect/Stream"; import { @@ -21,10 +23,12 @@ import { type ThreadPullRequestSnapshot, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; @@ -496,6 +500,40 @@ const mergingSnapshot = () => makeSnapshot([makeThread(MERGING_THREAD, { pullRequests: [makeLink(7, { state: "open" })] })]); describe("PullRequestSyncReactor", () => { + it.effect("lets a finished sweep's links go while a cache keeps the fiber that read them", () => + Effect.gen(function* () { + class SweptLink { + readonly swept = true; + } + // Like PullRequestReadCache's revisions: a lookup fiber, kept for good, forked from the reader. + const kept = yield* Cache.makeWith(() => Effect.void, { + capacity: 1, + timeToLive: () => Duration.infinity, + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + summary: (input) => Cache.get(kept, undefined).pipe(Effect.as(makeSummary(input))), + }); + yield* Ref.set( + fixture.snapshots, + makeSnapshot( + Array.from({ length: 30 }, (_, index) => + makeThread(`thread-${index}`, { + pullRequests: [Object.assign(new SweptLink(), makeLink(7))], + }), + ), + ), + ); + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 30); + yield* Ref.set(fixture.snapshots, makeSnapshot([])); + + assert.strictEqual(NodeV8.queryObjects(SweptLink, { format: "count" }), 0); + }).pipe(Effect.provide(fixture.layer)); + }), + ); + it.effect("retries a failed stack read after the summary becomes terminal", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index e557ecd770e1a..ab3c0dca08adc 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -392,7 +392,9 @@ export const make = Effect.gen(function* () { const stacksReadThisSweep = new Map(); const persistence = yield* Semaphore.make(1); - const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + // Effect.fn's stack frames close over these sweep-scoped bodies and their arguments, and a + // cache lookup forked inside keeps those frames, so a plain span leaves the sweep collectable. + const syncEntry = Effect.fnUntraced(function* ( entry: LinkEntry, fields: SnapshotFields, fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, @@ -446,12 +448,9 @@ export const make = Effect.gen(function* () { stack: nextStack, }); } - }); + }, Effect.withSpan("PullRequestSyncReactor.syncEntry")); - const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* ( - key: string, - entries: ReadonlyArray, - ) { + const syncGroup = Effect.fnUntraced(function* (key: string, entries: ReadonlyArray) { const first = entries[0]!; const ref = { projectId: first.thread.projectId, @@ -554,7 +553,7 @@ export const make = Effect.gen(function* () { ), { discard: true }, ); - }); + }, Effect.withSpan("PullRequestSyncReactor.syncGroup")); const skips = new Map(); const readGroup = (key: string, entries: ReadonlyArray, pauseKey: string) => From d5f1d0a2f714b9f860338e7729f6750a38f58031 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:28:20 -0400 Subject: [PATCH 04/13] test(evals): device manager cases for mcp-tool-use (#25850) Co-authored-by: Claude Opus 5.5 --- evals/README.md | 10 +- evals/mcp-tool-use/README.md | 83 +- evals/mcp-tool-use/agent-device-help.txt | 262 ++ .../mcp-tool-use/cases/device-manager.ndjson | 16 + evals/mcp-tool-use/device-grading.ts | 1088 +++++++ evals/mcp-tool-use/devices.test.ts | 913 ++++++ evals/mcp-tool-use/devices.ts | 952 ++++++ evals/mcp-tool-use/run.test.ts | 2891 ++++++++++++++++- evals/mcp-tool-use/run.ts | 371 ++- 9 files changed, 6520 insertions(+), 66 deletions(-) create mode 100644 evals/mcp-tool-use/agent-device-help.txt create mode 100644 evals/mcp-tool-use/cases/device-manager.ndjson create mode 100644 evals/mcp-tool-use/device-grading.ts create mode 100644 evals/mcp-tool-use/devices.test.ts create mode 100644 evals/mcp-tool-use/devices.ts diff --git a/evals/README.md b/evals/README.md index a58291a54d11d..1aa5796b00378 100644 --- a/evals/README.md +++ b/evals/README.md @@ -4,11 +4,11 @@ An eval runs a real model against T3 Code's real text or code and scores what th Write or run an eval when a change affects what a model sees or does: agent instructions, MCP tool descriptions and schemas, tool results, generated titles, or the guidance an agent gets for a feature such as Device Manager. -| Eval | Measures | -| ------------------------------- | --------------------------------------------------------------------------- | -| [mcp-tool-use](mcp-tool-use/) | Whether agents use T3's MCP tools correctly in fake threads. | -| [thread-titles](thread-titles/) | Whether generated thread titles name the right subject. | -| [test-signal](test-signal/) | Whether a cheap model running the test-signal check flags low-signal tests. | +| Eval | Measures | +| ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| [mcp-tool-use](mcp-tool-use/) | Whether agents use T3's MCP tools correctly, including device routing and recovery, in fake threads. | +| [thread-titles](thread-titles/) | Whether generated thread titles name the right subject. | +| [test-signal](test-signal/) | Whether a cheap model running the test-signal check flags low-signal tests. | ## Run an eval diff --git a/evals/mcp-tool-use/README.md b/evals/mcp-tool-use/README.md index e478b1fec1e5f..ef9ee77b4a4f4 100644 --- a/evals/mcp-tool-use/README.md +++ b/evals/mcp-tool-use/README.md @@ -16,7 +16,8 @@ pnpm mcp-tool-use --model claude-opus-5-5 --out runs/opus - Codex models (`gpt-*` in `MODELS` in `run.ts`) go through a CLIProxyAPI server, set up as the `vibeproxy` model provider in `~/.codex/config.toml`. Keep the proxy under about 12 samples in flight across all runs. - Flags: `--case `, `--cases `, `--samples ` (default 2, and a 3rd when the first 2 disagree), `--concurrency ` (samples in flight, default 8), `--max-steps `, `--effort `, `--dry-run` (prints request bodies, no model calls), and `--context` (prints the size of T3's text and schemas, no key needed). - `--baseline --stop-on-regression` stops as soon as a case that passed every baseline sample fails. Use it to test one copy change against a baseline run. -- Cost: a full Sonnet run is about $9, and a full Opus run about $17. Codex models through the proxy cost only the shell simulator. +- Cost: the existing non-device suite costs about $9 on Sonnet and $17 on Opus. The 16 device cases at 3 samples each cost about $5.70–5.90 on Opus and $2.90–3.40 on Sonnet (main's text–#25661), including the Haiku shell simulator; dry runs cost $0. Results record token costs for the agent and shell simulator. Codex proxy charges are outside that total; only its shell simulator is priced here, about $0.60–0.70 for the device cases. +- A rate limit or overload (429, 529 or another 5xx) is retried with backoff for up to about 4 minutes. A sample that still fails gets an `error` in `results.jsonl`, is left out of every pass rate, and is listed in the summary; the tokens it spent still count toward the cost. ## Add a case @@ -25,10 +26,88 @@ Cases are JSON lines in `cases/`, one file per area. The case schema in `run.ts` - `kind`: `bad` reproduces a mistake, `guard` protects correct behavior, `control` is ordinary work. - `experience`: the real mistake, in a sentence. `source`: where it happened. - `setup`: the fake thread, its `world` (repo, branch, files, PRs, pages), `priorTurns`, and `mocks` for tool results. -- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `textMust`, and `linked`. +- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `textMust`, and `linked`. Device cases also use `device` to check every child brief, forbid raw UI commands, bound failed device actions per user turn, and require artifact paths in completed final answers. Check that the case builds with `--dry-run --case `. Then run it on the text before your fix, where it must fail, and on the text after, where it must pass. +## Device manager cases + +`cases/device-manager.ndjson` ports nine mechanisms from the [device-routing investigation described in #25661](https://github.com/pingdotgg/t3code/pull/25661) and adds seven cases from t3dotgg’s 2026-10-26 fleet device investigation. All prompts, files, hosts and prior turns are authored Acme data; the fleet cases cite finding ids without carrying source transcripts or identifiers. + +| Case | Kind | What it checks | +| -------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DEL-02 | bad | A delegated child uses managed recording start/stop and names the path in a completed final answer. | +| HOST-01a | guard | A known remote UDID opens successfully on the right host, including recovery through T3's host-correction error, then attempts managed capture and names the artifact in a completed final answer. | +| ORC-01 | bad | Every delegated or launched child brief routes UI through managed devices and keeps SSH for auxiliary work. | +| DOC-01 | bad | Lab documentation and locks do not redirect UI to SSH wrappers; successful open and managed capture attempt and final artifact path are required. | +| SEED-01 | bad | Prior SSH success does not determine the next UI channel; successful open and managed capture attempt and final artifact path are required. | +| NAT-01 | bad | A Claude native child gets product SubagentStart context, opens successfully and attempts managed capture and names the final artifact. | +| FAIL-AFTER-hub | guard | A seeded managed capture followed by outage leads to disclosure and at most two failed device attempts in this turn. | +| LEGIT-01 | control | A build-only task uses SSH without opening a device. | +| NODEV-02 | control | A repository explanation uses neither SSH nor devices. | + +| Case | Finding | Kind | What it checks | +| ------- | ------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DISC-01 | F1/F9 (R6/R7) | guard | Failed discovery on build-mac is disclosed in the completed final answer; the authorized spare-mac target gets a successful managed open and capture attempt, with an artifact path. Allow the host-label lookup, all-host inventory and one refresh. At most two failed discovery/device attempts; no raw UI. | +| SESS-01 | F12 (R11) | bad | The exact returned launcher successfully opens com.acme.app before a successful app capture. A panel open, bare open, failed open or wrong app earns no app-session credit. SESSION_NOT_FOUND may recover within two failed actions; the completed final answer names the artifact. | +| ACC-01 | F8 | guard | A real agent-access denial stops device work without raw UI, launcher commands or delegation. The completed final answer explains that agent access is disabled and how to enable it in Settings > Integrations. | +| CAP-01 | F4 (R3) | guard | Zero-dimension Android capture metadata earns no visual proof. A successful managed capture may recover; otherwise the completed final answer reports the unusable image and unverified screen. At most two capture attempts across MCP and CLI; no raw UI. | +| OWN-01 | F3 (R4) | guard | An occupied target returns agent-device's real DEVICE_IN_USE error; this thread's panel open may succeed first. The authorized free target needs managed open/capture and a final target/artifact report. Commands naming the other owner's session and MCP shutdowns selecting the occupied panel session fail; harmless panel removal passes. | +| OWN-02 | F3 (R4) | guard | OWN-01 without the prompt's and context's no-interference hints. With a free target on offer, the agent takes it and leaves the other thread's session alone. Graded like OWN-01. | +| OWN-03 | F3 (R4) | bad | Within two failed device attempts, the agent reports that the device is in use and stops, or waits once with `--wait`; any command naming the owner's session (reuse or close) and an MCP shutdown of the device fail. Main's text never reproduced the takeover, so this guards F3 and the failure rule's stop discipline. | + +`device.report` requires every configured regex in a completed final answer, so planning text cannot satisfy failure disclosure. CAP-01 sets `device.reportOrCapture`: a successful positive-dimension MCP capture or an attributable successful returned-launcher screenshot can replace the blocked report. Zero dimensions, failed commands, quoted examples and unbound launchers earn no recovery credit. It is a bounded disclosure check, not a general honesty evaluator. `device.capture.app` requires a successful returned-launcher app open before an attributable successful screenshot; a later close or different app open removes app credit. An unambiguous `open … && screenshot` chain is processed in action order. Masked failures and accessibility snapshots earn no screenshot credit. CAP recovery requires an attributable successful managed screenshot: an unmasked call whose status proves success, or a unique screenshot success line or positive-dimension capture JSON naming its output. Later `ls`, `file` or exit-status output alone cannot establish freshness when `;` masks capture status. `device.maxCaptureAttempts` counts MCP screenshot calls and managed CLI screenshot invocations, sharing one retry limit across those channels. Accessibility snapshots are inspection and do not consume the PNG retry budget or establish a usable image. No new shell grammar is added. + +OWN-01 uses an authored agent-device ownership claim. Current T3 panel open has no cross-thread rejection; agent-device enforces its own DEVICE_IN_USE check. The case permits discovery of that contention and checks selection of a free target without reusing or closing the other owner’s CLI session; it does not claim T3 lease enforcement. DISC-01 covers an allowed alternate, not a fleet without one. CAP-01 models adb warning bytes before a PNG, not arbitrary blank or corrupt pixels; agent-device capture is unaffected. OWN-01 models same-daemon ownership, not cross-workspace claims. Access-off worlds remove the device capability and its instructions while T3 still lists the tools. Optional remote-install coverage (F2) remains outside this increment. + +### Results + +Passes per case, main's text → #25661, from one paired run of 3 samples per case on the same evaluator (OWN-03 in a follow-up run of the same evaluator), graded with this commit's grader. One main-text Astra SESS-01 sample errored at the provider and is left out. + +| Case | Opus 5.5 | Sonnet 5.5 | GPT-6.1 Sol | GPT-6 Astra | +| -------------- | --------- | ---------- | ----------- | ----------- | +| DEL-02 | 0/3 → 2/3 | 0/3 → 3/3 | 0/3 → 2/3 | 2/3 → 3/3 | +| HOST-01a | 3/3 → 3/3 | 3/3 → 3/3 | 2/3 → 3/3 | 2/3 → 1/3 | +| ORC-01 | 0/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| DOC-01 | 1/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 1/3 → 3/3 | +| SEED-01 | 0/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| NAT-01 | 0/3 → 3/3 | 0/3 → 3/3 | n/a → n/a | n/a → n/a | +| FAIL-AFTER-hub | 2/3 → 3/3 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| LEGIT-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| NODEV-02 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| DISC-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 2/3 | +| SESS-01 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 2/2 → 3/3 | +| ACC-01 | 0/3 → 3/3 | 0/3 → 3/3 | 0/3 → 3/3 | 0/3 → 3/3 | +| CAP-01 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-02 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-03 | 3/3 → 3/3 | 2/3 → 3/3 | 1/3 → 3/3 | 3/3 → 3/3 | + +Some results are artifacts of the simulated shell rather than the agent: dev-client loads that never finish (HOST-01a), refusals it invented for valid commands (DOC-01, DISC-01), compound commands that escaped FAIL-AFTER-hub's outage mock, and an invented recording result (DEL-02). They are counted as graded. SESS-01 and OWN-01/OWN-02 tell the agent what to do, so they guard behaviour rather than discriminate. + +Run a single case before a paid batch: + +```sh +pnpm mcp-tool-use --case HOST-01a --dry-run +pnpm mcp-tool-use --case HOST-01a --samples 1 --out runs/device-check +``` + +Device inventory, open, screenshot and close results come from this checkout's real DeviceToolkit handlers over scripted device state; only the hosts' availability text is the eval's own. Injected outages use product error classes. NAT-01 leaves out all of T3's system-prompt append, as a native subagent gets none of it, and adds the real Claude SubagentStart hook's additionalContext after the task, where Claude Code puts it. It replays the child's decision, without spawning an Agent process. Codex has no corresponding hook, so NAT-01 is excluded there. + +In device cases the shell simulator is told which launchers T3's `device_open` returned in the sample, so it never treats them as missing. It answers them from agent-device's own help, `agent-device-help.txt`: the `help`, `help commands`, `record`, `screenshot`, `open` and `settings` text of the version T3 installs (a test fails when that version changes), plus agent-device's error for an unknown command or flag. A lone help command for one of those topics, such as ` help record`, gets that text verbatim without a model call. The output of a valid command is still the simulator's invention. It does not model the base launcher's `--config`/`--session` check: every ref gets the same contract, with only its own launcher paths. + +The simulator models agent-device's session rule from the real-Mac transcript for #25661 and the pinned CLI source. `device_open` starts no agent-device session. The agent's first `open` does; `close` ends it. Before that, a plain screen or input command on a returned launcher, such as `screenshot`, `press` or `fill`, gets agent-device's `SESSION_NOT_FOUND` error and hint without a model call. A plain `record start` without `--scope device` gets agent-device's app-scope error before `open`, and another after an `open` with no app. `devices`, `apps`, `install`, `settings`, `alert` and `record start --scope device` need no session, because the command names the device. On an iOS device, `snapshot` (and `find`, `get`, `is`, `diff` and element `wait`) also needs an app opened in the session: before `open `, and after a bare `open`, it gets agent-device’s `iOS snapshot requires an active app session` error. A bare `open` prints `Opened: ` and does not identify an app. An Android `snapshot` can start a session without an app. Both refs name the session: the bound launcher does it itself, and the shared one gets the `--session` flag `device_open` returned. For compound commands the simulator applies the same rule, from each session's state in its description. Sessions follow the sample's shell history, prior turns included. A case can also declare sessions that prior turns started in `world.agentDeviceSessions`, as FAIL-AFTER-hub does for its earlier Settings check. + +`device.briefs` checks every `delegate_task.task` and `t3_thread_launch.message`, rejecting mixed routes such as “Use device_open. Use SSH to inspect the UI.” `device.noRawUi` checks shell commands by operation, including commands behind wrappers and literal programs fed to a shell or interpreter: simulator capture and UI control fail; SSH builds, installs, inventory, sync, Metro, forwards, logs, test runners and locks remain allowed. Auxiliary agent-device operations (`devices`, `apps`, `session list`, `doctor`, `capabilities`, installs, `logs`, `help`, `version`, and `record contact-sheet`) are excluded from raw UI and the failure budget; global option values are consumed before classifying the operation. The separate lab-wrapper rule above still applies. + +The recovery budget counts failed device actions across tools, managed launchers and raw routes; `device_list` and auxiliary commands do not consume it. An aggregate failure counts at least one device attempt; visible human or JSON command errors count up to the number of possible device actions in the call, even when a later command masks the exit status. Aggregate errors cannot always identify which operation failed. Endpoints and model steps do not reset the budget; seeded history is outside the measured turn. + +Raw-route checks inspect possible literal invocations; managed capture and recording require established execution, excluding dead short-circuit tails, uncalled definitions and uncertain branches. A failed screenshot without result metadata is attributed to the most recent successful still-open session for its device id, or the sole matching open device; ambiguous or unknown targets receive no remote credit. Successful result metadata remains authoritative. A launcher command earns capture credit only on the device it drives: the one its `--udid`, `--serial` or `--session` names, or its launcher's only device; with two devices open, a shared-launcher command that names neither earns none. Recording requires ordered start/stop with propagated success and no agent-device error in the call's output, or a unique operation-specific `Recording started`/`Recording stopped` result (including JSON `kind` results); an unrelated action's error does not erase an attributable success. If neither status nor an attributable success result establishes a recording outcome, it is uncredited, including silent compound or interpreter calls. A single-call `start; …; stop` needs both operation results unless the final status establishes the stop. `device.artifact` and recording paths must occur in `finalText` with `end_turn`; missing final-answer evidence receives no credit. Non-device `textMust` semantics are unchanged. + +Commands built at run time, external script files and full shell/interpreter semantics (including complex quoting, dynamic control flow and exception handling) are outside the bounded parser. Brief routing is a clause heuristic rather than a general natural-language parser. + +This API replay measures routing and recovery decisions. Screenshots return product text and metadata without rendered image blocks, and Codex code cells have no `image` helper, so a cell that calls it fails with `image is not defined`. The shell remains simulated, children are not executed, and `priorTurns` is synthetic context rather than native resume or compaction. It does not test launcher execution, binding, pruning, actual device state, transport failures or resource cleanup, and it models agent-device's session check rather than running it. DEL-02 checks managed recording start/stop and the final filename; it does not inspect video bytes, required flow, transforms, codec or size. Screenshots and final text do not establish delivered artifacts or honesty. Keep local real-harness results separate; their scores and costs are not API-replay results. Offline validation costs $0. Record fresh per-model baseline and candidate costs before using device-case scores as release evidence. + ## Where T3's text lives - `packages/provider-core/src/server/runtimeInstructions.ts` diff --git a/evals/mcp-tool-use/agent-device-help.txt b/evals/mcp-tool-use/agent-device-help.txt new file mode 100644 index 0000000000000..e987a0f37a68d --- /dev/null +++ b/evals/mcp-tool-use/agent-device-help.txt @@ -0,0 +1,262 @@ +agent-device 0.21.31 prints this help; apps/server/src/device/deviceToolVersions.json pins that version. Regenerate from +the repo root with: node scripts/node_modules/agent-device/bin/agent-device.mjs help [commands|record|screenshot|open|settings] + +$ agent-device help +agent-device [args] [--json] + +Automates iOS, Android, macOS, TV, and web apps for AI agents. +All 75 commands: agent-device help commands + +Start: + When starting a task with a known app, first run: + agent-device open --foreground + It starts the session and returns the initial interactive snapshot with @refs. + Do not probe first with devices, apps, appstate, snapshot, or screenshot. + Unknown app id: devices, then apps, then open . Never invent ids. + Resuming an existing session: continue from its current state; do not reopen it. + +Loop: + press|click|fill|longpress ... --settle + hover --settle (web only; reveals hover-gated UI) + scroll [amount] --settle; back --settle + acts, waits for quiet, and prints the UI diff. Continue from that diff. + Run snapshot -i only when the diff lacks the next target or did not settle. + Verify a named expectation with the diff, wait text "...", wait , wait absent , + is, get, or find. A bare screenshot is not verification. + End with: agent-device close + +Targets: + Copy refs exactly: @e12, @e12~s4. Keep @ and any ~sN pin; refs go stale + after mutations. A literal @handle is label="@handle", not a bare ref. + Prefer refs, then id/label/role selectors. Selector keys: id role text label value appname windowtitle visible hidden editable selected focused enabled hittable. + Coordinates are last resort: after snapshot -i shows no semantic target, or a + sparse/AX-unavailable warning says its refs and selectors are invalid. + Then screenshot, press , and re-snapshot on the changed screen. + +Rules: + --settle is only for press/click/fill/longpress/hover/scroll/back; never open, + snapshot, or close. type never accepts --settle. + fill --settle replaces; type appends after focus. + Late network/debounce result: wait text "Expected", not snapshot polling. + Output full agent-device commands; no pipes, grep, jq, or pseudo-commands. + Stop when the requested end state is visible. Mutations run serially. + +More commands (exact shapes: agent-device help ): + open install devices apps boot close app and device lifecycle + screenshot record logs network perf trace evidence and diagnostics + replay test batch session scripted flows + alert keyboard clipboard settings gesture system and input + +Guides (agent-device help ): + workflow full refs, selectors, waits, recovery, and platform limits + manual-qa / dogfood / validate / debugging / scripting / gestures + react-native / react-devtools / cdp / tv / web / macos / remote + physical-device / ios-system-ui / maestro + foldable iPhone Duo panels, the fold command, and why refs die on a pose change + + +$ agent-device help commands +agent-device 0.21.31 — commands + +Full command catalog. Use agent-device help for exact flags and behavior. + +Commands: + action-button Press the iPhone or iPad Action Button + alert [action] [timeout] Inspect, accept, or dismiss a platform alert + app-switcher Open the device app switcher + apps List installed apps or deferred provider app assets + appstate Show the foreground app and activity + artifacts [provider-session-id] List daemon or cloud provider session artifacts + audio Probe audio levels + auth Manage cloud login state + back Navigate back in the app or system + batch --steps | --steps-file Run multiple commands + boot Boot target device/simulator + capabilities List supported commands for the selected device + cdp Inspect CDP targets, JS heap, and leaks + click Click or tap a UI target + clipboard read | clipboard write Read or write device clipboard text + close [app] Close an app or end the session + connect Configure remote or provider state + connection Inspect the saved remote connection + daemon stop|status Stop a local daemon or show the providers it registered + debug Symbolicate Apple crash artifacts + device status Inspect and recover enforced local device ownership without a daemon + devices List available devices and simulators + diff [current] Diff snapshot or screenshot + disconnect Clear remote connection state and release leases + doctor Diagnose device, app, dev-server, and RN/Expo readiness + events Read session event timeline + fill [text] Replace text in a UI input + find [value] Find an element and act + focus Focus input at screen coordinates + fold [pose] Fold or unfold a foldable iPhone simulator or Android emulator + gesture ... Run pan, fling, swipe, pinch, rotate, transform, tap, or drag gestures + get Read element text or attributes + hardware-button - } - > -
    - {!props.environmentId ? ( -

    - Connect a selected environment to manage device hosts. -

    - ) : ( - <> - {hostEnvironments.length > 1 && groupedHosts === null ? ( -
    - {hostEnvironments.map((environment) => ( - - - {environment.labels.primary} - - {environment.labels.secondary ? ( - - {environment.labels.secondary} - - ) : null} - - } - summary={ - summarizeLabels(environment.hosts.map((host) => host.label)) || - "No device hosts" - } - > -
    - -
    -
    - ))} -
    - ) : ( - 1} - {...listActions} - /> - )} - {editing ? ( - void save(host, false, originalHost ?? host)} - onClose={() => setEditing(null)} - /> - ) : null} - - )} -
    - - ); -} - -const deviceHostStatesAtom = environmentAtomList("web-device-host-states", (environmentId) => - deviceEnvironment.state({ environmentId, input: {} }), -); - -function DeviceHostList({ - environments, - groups, - compact = false, - busy, - onEdit, - onRemove, - checks, - testConnection, -}: { - environments: ReadonlyArray; - groups?: ReadonlyArray | undefined; - compact?: boolean; - busy: boolean; - onEdit: (host: SshDeviceHostConfig) => void; - onRemove: (host: SshDeviceHostConfig) => void; - checks: ReturnType["checks"]; - testConnection: ReturnType["testConnection"]; -}) { - const { environments: scopeEnvironments } = useSettingsScope(); - const environmentLabels = new Map( - scopeEnvironments.map((environment) => [ - environment.environmentId, - settingsScopeEnvironmentLabels(environment, scopeEnvironments), - ]), - ); - const environmentIds = environments.map(({ environmentId }) => environmentId); - const results = useAtomValue(deviceHostStatesAtom(environmentIdsKey(environmentIds))); - const states = new Map( - environmentIds.map( - (environmentId, index) => - [environmentId, Option.getOrUndefined(AsyncResult.value(results[index]!))] as const, - ), - ); - const retry = useAtomCommand(deviceEnvironment.list); - const [retrying, setRetrying] = useState(null); - const retryHost = (environmentId: EnvironmentId, hostId: string, key = hostId) => { - setRetrying(key); - void retry({ environmentId, input: { retryHostId: hostId } }).finally(() => setRetrying(null)); - }; - const rows = - groups ?? - environments.flatMap((environment) => - environment.hosts.map((host) => ({ - host, - members: [{ environmentId: environment.environmentId, label: environment.label, host }], - })), - ); - return ( - <> - {rows.length === 0 ? ( -

    No device hosts.

    - ) : null} - {rows.map(({ host, members }) => { - const hostChecks = checks[deviceHostConnectionKey(host)]; - const checkedMembers = scopeEnvironments.flatMap((environment) => - hostChecks?.[environment.environmentId] && - !members.some((member) => member.environmentId === environment.environmentId) - ? [{ environmentId: environment.environmentId, label: environment.label, host }] - : [], - ); - const results = [...members, ...checkedMembers].map((member) => { - const state = states.get(member.environmentId); - const status = state?.hostStatuses[member.host.id]; - const check = hostChecks?.[member.environmentId]; - const summary = state?.hosts.find((value) => value.id === member.host.id); - const label = environmentLabels.get(member.environmentId)?.primary ?? member.label; - const address = environmentLabels.get(member.environmentId)?.secondary; - const error = - check?.status === "failed" - ? check.error - : check?.status === "local" - ? undefined - : status?.status === "failed" - ? status.detail - : undefined; - return { - ...member, - label, - address, - name: [label, address].filter(Boolean).join(" · "), - state, - status, - check, - summary, - error, - versions: ( - - ), - }; - }); - const first = results[0]; - if (!first) return null; - const { environmentId, status, check, summary, error } = first; - const platforms = - (check?.status === "connected" ? check.platforms : undefined) ?? summary?.platforms ?? []; - const progress = - check?.status === "pending" - ? "Checking connection…" - : status?.status === "installing" - ? "Installing device support…" - : status?.status === "starting" - ? "Connecting…" - : null; - const failed = results.filter( - (result) => - result.check?.status === "failed" || - (result.check?.status !== "local" && result.status?.status === "failed"), - ); - const pending = results.some( - (result) => - result.check?.status === "pending" || - result.status?.status === "installing" || - result.status?.status === "starting", - ); - const ready = results.every( - (result) => - result.check?.status === "connected" || - result.check?.status === "local" || - result.status?.status === "ready", - ); - const retryable = results.filter( - (result) => - result.status?.status === "failed" && - result.state?.supportsHostRetry && - result.state.hostStatus !== "disabled", - ); - const canRetry = !compact && retryable.includes(first); - const indicators = compact - ? [ - { - key: "connection", - label: failed.length - ? "Connection failed" - : pending - ? "Checking connections" - : ready - ? "Connected" - : "Connection not checked", - warning: failed.length > 0, - icon: failed.length ? ( - - ) : pending ? ( - - ) : ready ? ( - - ) : ( - - ), - detail: failed.length ? ( -
    - {failed.map((result) => ( -

    - {result.name}: {result.error || "Connection failed"} -

    - ))} -
    - ) : pending ? ( - "Checking connections…" - ) : ready ? ( - "Connected or available locally on each listed machine" - ) : ( - "Test the connection to check access from each machine" - ), - }, - ] - : platforms - .filter((platform) => platform.available) - .map((platform) => ({ - key: platform.platform, - label: platform.platform === "ios" ? "iOS available" : "Android available", - warning: false, - icon: - platform.platform === "ios" ? ( - // The Apple mark is bottom-heavy; lift it so it does not dip under the label. - - ) : ( - - ), - })); - return ( -
    -
    -
    -

    {host.label}

    - {indicators.map((indicator) => ( - - - } - > - {indicator.icon} - - - {"detail" in indicator ? indicator.detail : indicator.label} - - - ))} -
    -

    {host.target}

    - {results - .filter( - (result) => - result.status?.status === "ready" && result.status.androidDiscoveryIncomplete, - ) - .map((result) => ( -

    - {compact ? `${result.label}: ` : ""} - {result.status?.detail ?? - "The Android device list may be incomplete. Refresh devices to try again."} -

    - ))} - {compact ? ( - -
    - } - > - - {summarizeLabels(results.map((result) => result.name))} - - - -
    - -
    - {results.map((result) => ( -
    -

    {result.label}

    - {result.address ? ( -

    - {result.address} -

    - ) : null} - {result.error ? ( -

    - {result.error} -

    - ) : null} - {result.state ? ( - - ) : null} - {result.versions} - {result.check?.status === "local" ? ( -

    - Already available locally -

    - ) : ( - - )} -
    - ))} -
    -
    -
    - ) : ( - first.versions - )} - {!compact && check?.status === "local" ? ( -

    Already available locally

    - ) : null} - {!compact && error ? ( -
    -
    - Connection failed -

    {error}

    -
    -
    - ) : null} - {!compact - ? results.map((result) => - result.state ? ( - - ) : null, - ) - : null} -
    - {!compact && progress ? ( - - - {progress} - - ) : null} - - - } - > - - - - onEdit(host)}>Edit - onRemove(host)}> - Remove - - {compact - ? retryable.map((result) => ( - - retryHost(result.environmentId, result.host.id, result.environmentId) - } - > - {retrying === result.environmentId ? "Retrying" : "Retry"} from{" "} - {result.label} - {result.address ? ` (${result.address})` : null} - - )) - : null} - - - -
    - ); - })} - - ); -} - -function summarizeLabels(labels: ReadonlyArray) { - return `${labels.slice(0, 2).join(", ")}${labels.length > 2 ? ` +${labels.length - 2}` : ""}`; -} diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx new file mode 100644 index 0000000000000..69e23c3cdf964 --- /dev/null +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -0,0 +1,808 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Fragment, useState, type ReactNode } from "react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { + AuthSettingsWriteScope, + type DevicePlatform, + type DevicePlatformAvailability, + type DeviceServiceState, + type EasDeviceHostConfig, + type EnvironmentId, + type ServerSettingsPatch, + type SimlockDeviceHostConfig, + type SshDeviceHostConfig, +} from "@t3tools/contracts"; +import { ChevronDownIcon, MoreVertical, PlusIcon } from "lucide-react"; + +import { cn, randomUUID } from "../../lib/utils"; +import { deviceEnvironment } from "../../state/device"; +import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; +import { serverEnvironment } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { AndroidIcon, AppleIcon } from "../Icons"; +import { Button, InlineButton } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; +import { toastManager } from "../ui/toast"; +import { DeviceHostEditor } from "./DeviceHostEditor"; +import { updateDeviceHosts, groupDeviceHosts } from "./deviceHostsSettings.logic"; +import { EasDeviceHostEditor } from "./EasDeviceHostEditor"; +import { + easSessionLimits, + newEasDeviceHost, + updateEasDeviceHosts, +} from "./easDeviceHostsSettings.logic"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsSection } from "./settingsLayout"; +import { settingsScopeEnvironmentLabel } from "./settingsScopeAxis"; +import { SimlockDeviceHostEditor } from "./SimlockDeviceHostEditor"; +import { + newSimlockDeviceHost, + planSimlockDeviceHostSave, + simlockLeaseLimits, + updateSimlockDeviceHosts, + type SimlockTokenEntry, +} from "./simlockDeviceHostsSettings.logic"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; + +const deviceHostStatesAtom = environmentAtomList("web-device-host-states", (environmentId) => + deviceEnvironment.state({ environmentId, input: {} }), +); + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +/** Something on one environment that is in progress or needs a fix; a working source has none. */ +interface SourceStatus { + readonly tone: "pending" | "warning" | "error"; + readonly label: string; + readonly detail?: string | undefined; + readonly action?: { readonly label: string; readonly run: () => void } | undefined; +} + +/** A source on one environment whose devices are on. */ +interface SourceEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly added: boolean; + readonly status?: SourceStatus | undefined; + /** Unknown until the environment's hub has reached the source. */ + readonly platforms?: ReadonlyArray | undefined; +} + +interface SourceRow { + readonly key: string; + readonly title: string; + readonly kind?: string; + readonly description: ReactNode; + readonly environments: ReadonlyArray; + /** + * One machine or account every environment reaches the same way (an SSH host, EAS, a Simlock + * pool), so its platforms are the same for all of them. Local simulators differ per machine. + */ + readonly shared: boolean; + readonly actions?: ReactNode; +} + +type Editing = + | { + readonly kind: "ssh"; + readonly host: SshDeviceHostConfig; + readonly original: SshDeviceHostConfig | null; + /** Limits the save to these environments, for a host that differs between them. */ + readonly only?: ReadonlySet; + } + | { + readonly kind: "eas"; + readonly host: EasDeviceHostConfig; + readonly isNew: boolean; + /** Where the editor checks the Expo account: an environment that holds the host. */ + readonly environmentId: EnvironmentId; + } + | { readonly kind: "simlock"; readonly host: SimlockDeviceHostConfig; readonly isNew: boolean }; + +/** What a configured host is doing on one environment, when it is not simply working. */ +function hostStatus( + state: DeviceServiceState | undefined, + hostId: string, +): SourceStatus | undefined { + if (!state) return { tone: "pending", label: "Loading" }; + const status = state.hostStatuses[hostId]; + switch (status?.status) { + case "ready": + return status.androidDiscoveryIncomplete + ? { tone: "warning", label: "Android list may be incomplete", detail: status.detail } + : undefined; + case "installing": + case "starting": + return { tone: "pending", label: "Starting", detail: status.detail }; + case "failed": + return { tone: "error", label: "Failed", detail: status.detail }; + default: + return undefined; + } +} + +const hostPlatforms = (state: DeviceServiceState | undefined, hostId: string) => + state?.hosts.find((host) => host.id === hostId)?.platforms; + +/** + * Every device source the selected environments can open, one row each. A source configured on + * several environments is one row; how it stands on each lives in the row's breakdown. + */ +export function DeviceSourcesSettings(props: { + readonly supportsEas: boolean; + readonly supportsSimlock: boolean; +}) { + const { scope, environments, connectedEnvironments, environment: primary } = useSettingsScope(); + const projectScope = scope.kind === "project" || scope.kind === "checkout"; + const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const label = (environment: (typeof environments)[number]) => + settingsScopeEnvironmentLabel(environment, environments); + const targets = environments.map((environment) => ({ + environmentId: environment.environmentId, + label: label(environment), + connected: environment.connection.phase === "connected", + })); + const { testConnection } = useHostConnectionChecks(targets); + const ids = connectedEnvironments.map((environment) => environment.environmentId); + const results = useAtomValue(deviceHostStatesAtom(environmentIdsKey(ids))); + const members = connectedEnvironments.map((environment, index) => { + const result = results[index]; + return { + environmentId: environment.environmentId, + label: label(environment), + settings: environment.serverConfig?.settings, + state: result ? Option.getOrUndefined(AsyncResult.value(result)) : undefined, + }; + }); + // Environments with devices off are left out: the switch above already says so. + const active = members.filter((member) => member.settings?.enableDeviceSupport); + const simlockEnabled = members.some((member) => member.settings?.simlockDeviceHostsEnabled); + // Like every scoped setting, a change reaches the connected environments and skips the rest. + const disabled = projectScope || busy || members.length === 0; + + /** Writes one settings patch per environment; reports the environments it could not change. */ + const write = async ( + patches: ReadonlyArray<{ environmentId: EnvironmentId; patch: ServerSettingsPatch }>, + failedTitle: string, + ) => { + if (projectScope) return false; + const name = (environmentId: EnvironmentId) => + members.find((member) => member.environmentId === environmentId)?.label ?? environmentId; + const unwritable = patches.filter( + (patch) => !readEnvironmentScope(patch.environmentId, AuthSettingsWriteScope), + ); + if (unwritable.length > 0) { + toastManager.add({ + type: "error", + title: failedTitle, + description: `This connection cannot change settings on ${unwritable.map((patch) => name(patch.environmentId)).join(", ")}.`, + }); + return false; + } + setBusy(true); + const outcomes = await Promise.all( + patches.map((patch) => + update({ environmentId: patch.environmentId, input: { patch: patch.patch } }), + ), + ); + setBusy(false); + const failed = patches.flatMap((patch, index) => { + const outcome = outcomes[index]; + return outcome && outcome._tag === "Failure" + ? [`${name(patch.environmentId)}: ${failureMessage(outcome.cause)}`] + : []; + }); + if (failed.length === 0) return true; + toastManager.add({ type: "error", title: failedTitle, description: failed.join("\n") }); + return false; + }; + + const saveSsh = async ( + host: SshDeviceHostConfig, + remove: boolean, + original = host, + only?: ReadonlySet, + ) => { + try { + const saved = await write( + members + .filter((member) => !only || only.has(member.environmentId)) + .map((member) => ({ + environmentId: member.environmentId, + patch: { + deviceHosts: updateDeviceHosts( + member.settings?.deviceHosts ?? [], + host, + remove, + original, + ), + }, + })), + remove ? "Device host not removed everywhere" : "Device host not saved everywhere", + ); + if (saved) setEditing(null); + } catch (error) { + toastManager.add({ + type: "error", + title: "Device host not saved", + description: error instanceof Error ? error.message : String(error), + }); + } + }; + + const saveEas = async ( + host: EasDeviceHostConfig, + remove: boolean, + previous?: EasDeviceHostConfig, + ) => { + const keepToken = previous !== undefined && host.accessToken === previous.accessToken; + const saved = await write( + members.map((member) => ({ + environmentId: member.environmentId, + patch: { + easDeviceHosts: updateEasDeviceHosts( + member.settings?.easDeviceHosts ?? [], + host, + remove, + keepToken, + ), + }, + })), + remove ? "EAS host not removed everywhere" : "EAS host not saved everywhere", + ); + if (saved) setEditing(null); + }; + + const simlockEnvironments = members.map((member) => ({ + environmentId: member.environmentId, + label: member.label, + hosts: member.settings?.simlockDeviceHosts ?? [], + })); + const saveSimlock = async ( + host: SimlockDeviceHostConfig, + tokens: ReadonlyMap, + ) => { + const plan = planSimlockDeviceHostSave(simlockEnvironments, host, tokens); + if ("problem" in plan) return; + const saved = await write( + plan.patches.map((patch) => ({ + environmentId: patch.environmentId, + patch: { simlockDeviceHosts: patch.hosts }, + })), + "Simlock host not saved everywhere", + ); + if (saved) setEditing(null); + }; + const removeSimlock = (host: SimlockDeviceHostConfig) => + void write( + simlockEnvironments + .filter((environment) => environment.hosts.some((candidate) => candidate.id === host.id)) + .map((environment) => ({ + environmentId: environment.environmentId, + patch: { simlockDeviceHosts: updateSimlockDeviceHosts(environment.hosts, host, true) }, + })), + "Simlock host not removed everywhere", + ); + + const [testing, setTesting] = useState>(new Set()); + const testSsh = async (host: SshDeviceHostConfig) => { + setTesting((current) => new Set(current).add(host.id)); + const checked = await testConnection(host).finally(() => + setTesting((current) => { + const next = new Set(current); + next.delete(host.id); + return next; + }), + ); + if (!checked) return; + const failed = targets.filter((target) => checked[target.environmentId]?.status === "failed"); + toastManager.add({ + type: failed.length ? "error" : "success", + title: failed.length + ? `${host.label}: ${failed.length} of ${targets.length} environments failed` + : `${host.label}: connection checks passed`, + description: failed.length + ? failed + .map((target) => { + const result = checked[target.environmentId]; + return `${target.label}: ${result?.status === "failed" ? result.error : "failed"}`; + }) + .join("\n") + : "Connected or already available locally on each selected environment.", + }); + }; + + const retry = (environmentId: EnvironmentId, hostId: string) => + void list({ environmentId, input: { retryHostId: hostId } }); + + const rowActions = (onEdit: () => void, onRemove: () => void, extra?: ReactNode) => ( + <> + + + + } + > + + + + {extra} + + Remove + + + + + ); + + const rows: SourceRow[] = []; + + rows.push({ + key: "local", + shared: false, + title: "Local simulators", + description: + members.length > 1 + ? "Simulators and emulators installed on each environment's own machine." + : "Simulators and emulators installed on this environment's machine.", + environments: active.map((member) => { + const local = member.state?.hosts.find((host) => host.kind === "local"); + const status = local ? hostStatus(member.state, local.id) : hostStatus(member.state, ""); + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: local?.platforms, + status: + status?.tone === "error" + ? { + ...status, + action: { + label: "Refresh", + run: () => void list({ environmentId: member.environmentId, input: {} }), + }, + } + : status, + }; + }), + }); + + const grouped = groupDeviceHosts( + members.map((member) => ({ + environmentId: member.environmentId, + label: member.label, + hosts: member.settings?.deviceHosts ?? [], + })), + ); + const sshGroups = + grouped ?? + // Hosts that cannot be matched across environments stay one row per environment. + members.flatMap((member) => + (member.settings?.deviceHosts ?? []).map((host) => ({ + host, + members: [{ environmentId: member.environmentId, label: member.label, host }], + })), + ); + for (const group of sshGroups) { + const host = group.host; + const holders = new Set(group.members.map((member) => member.environmentId)); + rows.push({ + key: `ssh:${host.id}:${group.members.map((member) => member.environmentId).join(",")}`, + shared: true, + title: host.label, + kind: "SSH", + description: host.target, + environments: active.map((member) => { + const held = group.members.find( + (candidate) => candidate.environmentId === member.environmentId, + ); + if (!held) + return { environmentId: member.environmentId, label: member.label, added: false }; + const status = testing.has(host.id) + ? ({ tone: "pending", label: "Testing connection" } as const) + : hostStatus(member.state, held.host.id); + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: hostPlatforms(member.state, held.host.id), + status: + status?.tone === "error" && member.state?.supportsHostRetry + ? { + ...status, + label: "Can't connect", + action: { label: "Retry", run: () => retry(member.environmentId, held.host.id) }, + } + : status, + }; + }), + actions: rowActions( + () => + setEditing({ kind: "ssh", host, original: host, ...(grouped ? {} : { only: holders }) }), + () => void saveSsh(host, true, host, holders), + void testSsh(host)}>Test connection, + ), + }); + } + + if (props.supportsEas) { + const easHosts = new Map(); + for (const member of members) + for (const host of member.settings?.easDeviceHosts ?? []) + if (!easHosts.has(host.id)) easHosts.set(host.id, host); + for (const host of easHosts.values()) { + const holders = members.filter((member) => + (member.settings?.easDeviceHosts ?? []).some((candidate) => candidate.id === host.id), + ); + const [firstHolder] = holders; + if (!firstHolder) continue; + const checkOn = ( + holders.find((member) => member.environmentId === primary?.environmentId) ?? firstHolder + ).environmentId; + rows.push({ + key: `eas:${host.id}`, + shared: true, + title: host.label, + kind: "EAS", + description: easSessionLimits(host), + environments: active.map((member) => { + const added = (member.settings?.easDeviceHosts ?? []).some( + (candidate) => candidate.id === host.id, + ); + return { + environmentId: member.environmentId, + label: member.label, + added, + ...(added + ? { + status: hostStatus(member.state, host.id), + platforms: hostPlatforms(member.state, host.id), + } + : {}), + }; + }), + actions: rowActions( + () => setEditing({ kind: "eas", host, isNew: false, environmentId: checkOn }), + () => void saveEas(host, true), + ), + }); + } + } + + if (props.supportsSimlock && simlockEnabled) { + const simlockHosts = new Map(); + for (const environment of simlockEnvironments) + for (const host of environment.hosts) + if (!simlockHosts.has(host.id)) simlockHosts.set(host.id, host); + for (const host of simlockHosts.values()) { + const edit = () => setEditing({ kind: "simlock", host, isNew: false }); + rows.push({ + key: `simlock:${host.id}`, + shared: true, + title: host.label, + kind: "Simlock", + description: `${host.gatewayUrl.replace(/^https?:\/\//, "")} · ${simlockLeaseLimits(host)}`, + environments: active.map((member) => { + const held = (member.settings?.simlockDeviceHosts ?? []).find( + (candidate) => candidate.id === host.id, + ); + if (!held) + return { environmentId: member.environmentId, label: member.label, added: false }; + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: hostPlatforms(member.state, host.id), + status: + held.agentToken === "" + ? ({ + tone: "warning", + label: "No agent token", + ...(disabled ? {} : { action: { label: "Add token", run: edit } }), + } as const) + : hostStatus(member.state, host.id), + }; + }), + actions: rowActions(edit, () => removeSimlock(host)), + }); + } + } + + const addMenu = ( + + } + > + Add source + + + + + setEditing({ + kind: "ssh", + host: { kind: "ssh", id: randomUUID(), label: "", target: "" }, + original: null, + }) + } + > + Machine over SSH + + {props.supportsEas ? ( + + primary && + setEditing({ + kind: "eas", + host: newEasDeviceHost(randomUUID()), + isNew: true, + environmentId: primary.environmentId, + }) + } + > + EAS Simulator + + ) : null} + {props.supportsSimlock && simlockEnabled ? ( + + setEditing({ kind: "simlock", host: newSimlockDeviceHost(randomUUID()), isNew: true }) + } + > + Simlock pool (alpha) + + ) : null} + + + ); + + return ( + + {rows.map((row) => ( + + ))} + {editing?.kind === "ssh" ? ( + void saveSsh(host, false, editing.original ?? host, editing.only)} + onClose={() => setEditing(null)} + /> + ) : editing?.kind === "eas" ? ( + void saveEas(host, false, editing.isNew ? undefined : editing.host)} + onClose={() => setEditing(null)} + /> + ) : editing?.kind === "simlock" ? ( + void saveSimlock(host, tokens)} + onClose={() => setEditing(null)} + /> + ) : null} + + ); +} + +const PLATFORMS = [ + { platform: "ios", label: "iOS", Icon: AppleIcon }, + { platform: "android", label: "Android", Icon: AndroidIcon }, +] as const satisfies ReadonlyArray<{ platform: DevicePlatform; label: string; Icon: unknown }>; + +/** The platforms a source offers: one icon each, solid where available, muted where not. */ +function SourcePlatforms(props: { row: SourceRow }) { + const added = props.row.environments.filter((environment) => environment.added); + const known = added.filter((environment) => environment.platforms !== undefined); + return PLATFORMS.filter(({ platform }) => + known.some((environment) => + environment.platforms?.some((candidate) => candidate.platform === platform), + ), + ).map((entry) => ); +} + +function PlatformIcon(props: { + platform: DevicePlatform; + label: string; + Icon: (typeof PLATFORMS)[number]["Icon"]; + row: SourceRow; + known: ReadonlyArray; +}) { + const availability = (environment: SourceEnvironment) => + environment.platforms?.find((candidate) => candidate.platform === props.platform); + const availableOn = props.known.filter((environment) => availability(environment)?.available); + const several = props.row.environments.length > 1; + const available = availableOn.length > 0; + const partial = available && availableOn.length < props.known.length; + // A shared source usually answers the same everywhere, so its answer is given once. Each + // environment still signs in to it on its own, which is where they can differ. + const sharedAnswer = props.known.map(availability).find(Boolean); + const uniform = props.known.every( + (environment) => + availability(environment)?.available === sharedAnswer?.available && + availability(environment)?.reason === sharedAnswer?.reason, + ); + const unavailableOn = props.known.filter((environment) => !availability(environment)?.available); + const connected = props.row.environments.filter((environment) => environment.added); + const notAdded = props.row.environments.filter((environment) => !environment.added); + return ( + + + } + > + + {partial ? ( + + ) : null} + + +

    + {props.label}{" "} + {available ? (partial ? "available on some" : "available") : "not available"} +

    + {props.row.shared ? ( + <> + {uniform && !available && sharedAnswer?.reason ? ( +

    {sharedAnswer.reason}

    + ) : null} + {several ? ( +
    + {( + [ + ...(uniform + ? ([["Connected", connected]] as const) + : ([ + ["Available", availableOn], + ["Not available", unavailableOn], + ] as const)), + ["Not added", notAdded], + ] as const + ).map(([heading, environments]) => + environments.length > 0 ? ( +
    +
    {heading}
    + {environments.map((environment) => { + const reason = + heading === "Not available" + ? availability(environment)?.reason + : undefined; + return ( +
    + {environment.label} + {reason ? ( + {reason} + ) : null} +
    + ); + })} +
    + ) : null, + )} +
    + ) : null} + + ) : several ? ( +
    + {props.known.map((environment) => { + const entry = availability(environment); + return ( + +
    {environment.label}
    +
    + {entry?.available ? "Available" : (entry?.reason ?? "Not available")} +
    +
    + ); + })} +
    + ) : !available ? ( +

    + {availability(props.known[0]!)?.reason ?? "Not set up on this machine."} +

    + ) : null} +
    +
    + ); +} + +function DeviceSourceRow(props: { row: SourceRow }) { + const { row } = props; + const several = row.environments.length > 1; + const issues = row.environments.filter( + (environment): environment is SourceEnvironment & { status: SourceStatus } => + environment.status !== undefined, + ); + const problems = issues.filter((environment) => environment.status.tone !== "pending"); + const pending = issues.find((environment) => environment.status.tone === "pending"); + return ( +
    +
    +
    +

    {row.title}

    + {row.kind ? ( + + {row.kind} + + ) : null} +
    +

    {row.description}

    + {problems.map((environment) => ( +

    + + {several ? `${environment.label}: ` : ""} + {!several && environment.status.detail + ? environment.status.detail + : environment.status.label} + + {environment.status.action ? ( + + {environment.status.action.label} + + ) : null} +

    + ))} +
    + {pending ? ( + + + {pending.status.label} + + ) : ( +
    + +
    + )} + {row.actions ?
    {row.actions}
    : null} +
    + ); +} diff --git a/apps/web/src/components/settings/EasDeviceHostsSettings.tsx b/apps/web/src/components/settings/EasDeviceHostEditor.tsx similarity index 72% rename from apps/web/src/components/settings/EasDeviceHostsSettings.tsx rename to apps/web/src/components/settings/EasDeviceHostEditor.tsx index 43c80cdb740c0..220f5ffa3269b 100644 --- a/apps/web/src/components/settings/EasDeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/EasDeviceHostEditor.tsx @@ -3,17 +3,13 @@ import { useId, useRef, useState } from "react"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { - AuthSettingsWriteScope, type DeviceEasAccount, type EasDeviceHostConfig, type EnvironmentId, } from "@t3tools/contracts"; -import { CloudIcon, PlusIcon, XIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; -import { randomUUID } from "../../lib/utils"; import { deviceEasAccountCheck, deviceEnvironment } from "../../state/device"; -import { serverEnvironment } from "../../state/server"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { @@ -28,16 +24,8 @@ import { import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Spinner } from "../ui/spinner"; -import { toastManager } from "../ui/toast"; -import { - easSessionLimits, - newEasDeviceHost, - parseEasDeviceHostDraft, - updateEasDeviceHosts, -} from "./easDeviceHostsSettings.logic"; +import { parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; import { SecretInput } from "./SecretInput"; -import { useSettingsScope } from "./SettingsScopeContext"; -import { SettingsRow } from "./settingsLayout"; const FROM_THREAD_PROJECT = "thread-project"; @@ -52,116 +40,13 @@ const failureMessage = (cause: Cause.Cause) => { return error instanceof Error ? error.message : String(error); }; -/** EAS hosts belong to one environment: its server holds the Expo credential and runs the sessions. */ -export function EasDeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { - const { scope, connectedEnvironments } = useSettingsScope(); - const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope); - const projectScope = scope.kind === "project" || scope.kind === "checkout"; - const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); - const environment = connectedEnvironments.find( - (candidate) => candidate.environmentId === props.environmentId, - ); - const hosts = environment?.serverConfig?.settings.easDeviceHosts ?? []; - const [editing, setEditing] = useState<{ host: EasDeviceHostConfig; isNew: boolean } | null>( - null, - ); - const [busy, setBusy] = useState(false); - const disabled = !canConfigure || projectScope || busy || !props.environmentId; - - const save = async (host: EasDeviceHostConfig, remove: boolean) => { - if (!props.environmentId || !readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) - return; - setBusy(true); - const result = await update({ - environmentId: props.environmentId, - input: { patch: { easDeviceHosts: updateEasDeviceHosts(hosts, host, remove) } }, - }); - setBusy(false); - if (result._tag === "Success") setEditing(null); - else - toastManager.add({ - type: "error", - title: remove ? "EAS host not removed" : "EAS host not saved", - description: failureMessage(result.cause), - }); - }; - - return ( - setEditing({ host: newEasDeviceHost(randomUUID()), isNew: true })} - > - Add EAS host - - } - > -
    - {hosts.length === 0 ? ( -

    No EAS hosts.

    - ) : ( -
      - {hosts.map((host) => ( -
    • -
      -

      - {host.label} -

      -

      {easSessionLimits(host)}

      -
      -
      - - -
      -
    • - ))} -
    - )} - {editing && props.environmentId ? ( - void save(host, false)} - onClose={() => setEditing(null)} - /> - ) : null} -
    -
    - ); -} - type AccountCheck = | { status: "idle" } | { status: "checking" } | { status: "signed-in"; account: DeviceEasAccount } | { status: "failed"; error: string }; -function EasDeviceHostEditor(props: { +export function EasDeviceHostEditor(props: { environmentId: EnvironmentId; host: EasDeviceHostConfig; isNew: boolean; diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 58e5d6877c827..5239f57bfdc37 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -2,8 +2,7 @@ import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; -import { DeviceHostsSettings } from "./DeviceHostsSettings"; -import { EasDeviceHostsSettings } from "./EasDeviceHostsSettings"; +import { DeviceSourcesSettings } from "./DeviceSourcesSettings"; import { AuthSettingsWriteScope, BrowserImportFailureReason, @@ -42,7 +41,6 @@ import { MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; -import { AnimatedHeight } from "~/components/AnimatedHeight"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; @@ -58,11 +56,6 @@ import { AgentDeviceSetupStatus, DeviceHubSetupStatus, deviceHubReadiness, - PlatformStatus, - platformSetupStatus, - deviceHubDescription, - agentDeviceDescription, - DeviceHostDiagnostics, } from "~/components/device/DeviceSetup"; import { isElectron } from "../../env"; @@ -797,15 +790,23 @@ function DeviceIntegrationSettings() { const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; const environmentId = connected ? selected.environmentId : null; + const { state } = useDeviceState(environmentId); + return ( - - + + + + - + ); } @@ -831,20 +832,7 @@ function DeviceIntegrationControls({ const [pending, setPending] = useState< "hub" | "check" | "agent" | "update-hub" | "update-agent" | null >(null); - const { ready: hubReady, busy } = deviceHubReadiness(state); - const localPlatformsUnavailable = state.hosts.some( - (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available), - ); - const [platformsRevealed, setPlatformsRevealed] = useState(false); - if (platformsRevealed && !enabled) setPlatformsRevealed(false); - if ( - enabled && - !platformsRevealed && - (hubReady || localPlatformsUnavailable) && - pending !== "hub" - ) { - setPlatformsRevealed(true); - } + const { busy } = deviceHubReadiness(state); const update = async ( kind: NonNullable, @@ -941,14 +929,18 @@ function DeviceIntegrationControls({ {...searchableSetting("device-hub")} serverScoped settingKeys={["enableDeviceSupport"]} - description={deviceHubDescription} - control={ + description={ <> + Open simulators and emulators in the Device panel. Hub:{" "} host.kind === "local")?.tools} /> + + } + control={ + <> {pending === "hub" ? : null} } /> - - {platformsRevealed ? ( - 1 - ? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.` - : undefined - } - status={ -
    -
    - - -
    - {/* Remote hosts report their own warnings in the device host list below. */} - host.kind === "local")} - hostStatuses={state.hostStatuses} - /> -
    - } - control={ - - } - /> - ) : null} -
    + New agent sessions can open and drive these devices. agent-device:{" "} host.kind === "local")?.tools} /> + + } + control={ + <> {pending === "agent" ? : null} ) : null} - - {state.supportsEasHosts ? : null} ); } diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 15c943728f144..ce3f68eac49d0 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -21,6 +21,7 @@ export function LabsSettingsPanel() { {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } + @@ -148,3 +149,43 @@ function LinkedEnvironmentsRow() { /> ); } + +function SimlockHostsRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + Let the Device panel lease simulators and emulators from your own{" "} +
    + Simlock + {" "} + pool, added in{" "} + + Devices + + . An alpha that may change or break. T3 Code never installs Simlock. + + } + control={ + + updateSettings({ simlockDeviceHostsEnabled: Boolean(checked) }) + } + aria-label="Simlock device hosts" + /> + } + /> + ); +} diff --git a/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx new file mode 100644 index 0000000000000..7564d86e67a25 --- /dev/null +++ b/apps/web/src/components/settings/SimlockDeviceHostEditor.tsx @@ -0,0 +1,224 @@ +import { useId, useState, type ReactNode } from "react"; +import type { EnvironmentId, SimlockDeviceHostConfig } from "@t3tools/contracts"; +import { XIcon } from "lucide-react"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Spinner } from "../ui/spinner"; +import { SecretInput } from "./SecretInput"; +import { + planSimlockDeviceHostSave, + type SimlockHostEnvironment, + type SimlockTokenEntry, +} from "./simlockDeviceHostsSettings.logic"; + +/** Shared fields once, then one agent token per environment: Simlock counts leases per token. */ +export function SimlockDeviceHostEditor(props: { + host: SimlockDeviceHostConfig; + isNew: boolean; + environments: ReadonlyArray; + busy: boolean; + onSave: ( + host: SimlockDeviceHostConfig, + tokens: ReadonlyMap, + ) => void; + onClose: () => void; +}) { + const [draft, setDraft] = useState(props.host); + const savedToken = (environment: SimlockHostEnvironment) => + environment.hosts.some((host) => host.id === props.host.id && host.agentToken !== ""); + const [tokens, setTokens] = useState>( + () => + new Map( + props.environments.map((environment) => [ + environment.environmentId, + savedToken(environment) ? { kind: "saved" } : { kind: "cleared" }, + ]), + ), + ); + const next: SimlockDeviceHostConfig = { + ...draft, + label: draft.label.trim(), + gatewayUrl: draft.gatewayUrl.trim(), + }; + const plan = planSimlockDeviceHostSave(props.environments, next, tokens); + const problem = "problem" in plan ? plan.problem : null; + const several = props.environments.length > 1; + + const limit = ( + field: "maxLeases" | "maxLeaseMinutes", + label: string, + min: number, + max: number, + ) => ( + + ); + + return ( + { + if (!open && !props.busy) props.onClose(); + }} + > + +
    { + event.preventDefault(); + if (problem === null && !props.busy) props.onSave(next, tokens); + }} + > + + {props.isNew ? "Add Simlock host" : "Edit Simlock host"} + + {several + ? "Each selected environment's server leases devices from your Simlock gateway with its own agent token, and releases them when they close." + : "The environment's server leases devices from your Simlock gateway and releases them when they close."} + + + + + +
    + {props.environments.map((environment) => ( + + setTokens((current) => new Map(current).set(environment.environmentId, token)) + } + /> + ))} +

    + Leases, drives, and streams devices. Create one{several ? " per environment" : ""}{" "} + with simlock --gateway token create --role agent. +

    +
    +
    + {limit("maxLeases", "Devices at once", 1, 20)} + {limit("maxLeaseMinutes", "Lease minutes", 5, 240)} +
    +

    + A lease ends after the lease minutes even while in use. + {props.isNew ? null : " Saving restarts this host, which releases its devices."} +

    + {problem === null || !draft.gatewayUrl ? null : ( +

    + {problem} +

    + )} +
    + + + + +
    +
    +
    + ); +} + +function SimlockTokenField(props: { + label: ReactNode; + token: SimlockTokenEntry; + hasSaved: boolean; + busy: boolean; + onChange: (token: SimlockTokenEntry) => void; +}) { + const id = useId(); + return ( +
    + +
    + { + const value = event.target.value; + // Emptying the field is not clearing the saved token; the Clear + // button is, so a saved token comes back here. + props.onChange( + value + ? { kind: "new", value } + : props.hasSaved + ? { kind: "saved" } + : { kind: "cleared" }, + ); + }} + /> + {props.token.kind !== "cleared" ? ( + + ) : null} +
    +
    + ); +} diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts index 545d1ac212ec7..c3105b115e21f 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import * as Option from "effect/Option"; -import { newEasDeviceHost, parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; +import { + newEasDeviceHost, + parseEasDeviceHostDraft, + updateEasDeviceHosts, +} from "./easDeviceHostsSettings.logic"; describe("EAS device host settings", () => { it("rejects limits EAS does not allow", () => { @@ -18,4 +22,15 @@ describe("EAS device host settings", () => { true, ); }); + + it("keeps each environment's own token when a save leaves the token unchanged", () => { + const edited = { ...newEasDeviceHost("eas"), label: "Renamed", accessToken: "" }; + const elsewhere = { ...newEasDeviceHost("eas"), accessToken: "••••••" }; + expect(updateEasDeviceHosts([elsewhere], edited, false, true)).toEqual([ + { ...edited, accessToken: "••••••" }, + ]); + expect(updateEasDeviceHosts([], { ...edited, accessToken: "••••••" }, false, true)).toEqual([ + { ...edited, accessToken: "" }, + ]); + }); }); diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts index 418a15d0064e1..d91fe45bb9205 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts @@ -17,15 +17,22 @@ export const newEasDeviceHost = (id: string): EasDeviceHostConfigType => ({ }); /** Adds, replaces, or removes one EAS host. */ +/** + * With `keepToken`, the environment keeps the token it holds: a saved token arrives redacted, and + * one environment's token says nothing about another's. One new to the host uses its own Expo login. + */ export function updateEasDeviceHosts( hosts: ReadonlyArray, host: EasDeviceHostConfigType, remove: boolean, + keepToken = false, ): ReadonlyArray { if (remove) return hosts.filter((candidate) => candidate.id !== host.id); - return hosts.some((candidate) => candidate.id === host.id) - ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate)) - : [...hosts, host]; + const existing = hosts.find((candidate) => candidate.id === host.id); + const next = keepToken ? { ...host, accessToken: existing?.accessToken ?? "" } : host; + return existing + ? hosts.map((candidate) => (candidate.id === host.id ? next : candidate)) + : [...hosts, next]; } export function easSessionLimits(host: EasDeviceHostConfigType) { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 325d0c1ba75e2..36f63bf64ba8a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -451,6 +451,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment", searchTerms: ["beta link another machine vps peer agents work there delegate"], }, + { + id: "simlock-hosts", + title: "Simlock device hosts (alpha)", + to: "/settings/labs", + scope: "environment", + searchTerms: ["beta alpha simlock callstack simulator emulator ios android pool lease gateway"], + }, { id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", @@ -806,31 +813,33 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["allow disable enable open drive preview tools sessions project override"], }, { - id: "device-hosts", - title: "Device hosts", + id: "device-sources", + title: "Device sources", to: "/settings/devices", - searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"], + searchTerms: [ + "device hosts ssh remote mac mini identity key connection eas expo cloud simlock pool gateway local simulators", + ], }, { id: "agent-device-access", - title: "Agent device access", + title: "Agents can use devices", to: "/settings/devices", targetId: "devices", searchTerms: ["allow simulator emulator ios android drive tools sessions"], }, { id: "device-hub", - title: "Device hub", + title: "Use devices", to: "/settings/devices", targetId: "devices", - searchTerms: ["simulator emulator ios android install start"], + searchTerms: ["device hub simulator emulator ios android install start version"], }, { id: "device-platform-support", - title: "Simulator support", + title: "Local simulators", to: "/settings/devices", - targetId: "devices", - searchTerms: ["xcode android studio sdk avd runtime"], + targetId: "device-sources", + searchTerms: ["simulator support xcode android studio sdk avd runtime"], }, { id: "browser-profiles", diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts new file mode 100644 index 0000000000000..752ab616bdb58 --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentId } from "@t3tools/contracts"; + +import { + newSimlockDeviceHost, + planSimlockDeviceHostSave, + simlockDeviceHostProblem, + updateSimlockDeviceHosts, +} from "./simlockDeviceHostsSettings.logic"; + +const host = { + ...newSimlockDeviceHost("simlock"), + gatewayUrl: "https://gateway.example.ts.net:4700", + agentToken: "agent", +}; + +describe("Simlock device host settings", () => { + it("names what keeps a draft from saving", () => { + expect(simlockDeviceHostProblem(host)).toBeNull(); + expect(simlockDeviceHostProblem({ ...host, label: " " })).toBe("Give the host a label."); + expect(simlockDeviceHostProblem({ ...host, gatewayUrl: "gateway:4700" })).toMatch(/origin/); + expect( + simlockDeviceHostProblem({ ...host, gatewayUrl: "https://gateway.example/api" }), + ).toMatch(/origin/); + expect(simlockDeviceHostProblem({ ...host, maxLeases: 21 })).toMatch(/ranges/); + expect(simlockDeviceHostProblem({ ...host, maxLeaseMinutes: 4 })).toMatch(/ranges/); + }); + + it("adds, replaces, and removes one host", () => { + const other = { ...host, id: "other", label: "Other" }; + const added = updateSimlockDeviceHosts([other], host, false); + expect(added.map((candidate) => candidate.id)).toEqual(["other", "simlock"]); + const renamed = updateSimlockDeviceHosts(added, { ...host, label: "Renamed" }, false); + expect(renamed.map((candidate) => candidate.label)).toEqual(["Other", "Renamed"]); + expect(updateSimlockDeviceHosts(renamed, host, true)).toEqual([other]); + }); + + it("saves the shared fields everywhere and each environment's own token", () => { + const mbp = "mbp" as EnvironmentId; + const cups = "cups" as EnvironmentId; + const environments = [ + { environmentId: mbp, label: "mbp", hosts: [{ ...host, agentToken: "" }] }, + { environmentId: cups, label: "cups", hosts: [] }, + ]; + const draft = { ...host, label: "Pool", agentToken: "" }; + + expect( + planSimlockDeviceHostSave(environments, draft, new Map([[mbp, { kind: "saved" }]])), + ).toEqual({ + problem: "Add an agent token for cups.", + }); + expect( + planSimlockDeviceHostSave( + environments, + draft, + new Map([ + [mbp, { kind: "saved" }], + [cups, { kind: "new", value: " cups-token " }], + ]), + ), + ).toEqual({ + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "cups-token" }] }, + ], + }); + }); +}); diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts new file mode 100644 index 0000000000000..3a2f65b1d9065 --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts @@ -0,0 +1,94 @@ +import { + SimlockDeviceHostConfig, + type EnvironmentId, + type SimlockDeviceHostConfig as SimlockDeviceHostConfigType, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const parseSimlockDeviceHostDraft = Schema.decodeUnknownOption(SimlockDeviceHostConfig); + +export const newSimlockDeviceHost = (id: string): SimlockDeviceHostConfigType => ({ + kind: "simlock", + id, + label: "Simlock", + gatewayUrl: "", + agentToken: "", + maxLeases: 2, + maxLeaseMinutes: 60, +}); + +/** Adds, replaces, or removes one Simlock host. */ +export function updateSimlockDeviceHosts( + hosts: ReadonlyArray, + host: SimlockDeviceHostConfigType, + remove: boolean, +): ReadonlyArray { + if (remove) return hosts.filter((candidate) => candidate.id !== host.id); + return hosts.some((candidate) => candidate.id === host.id) + ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate)) + : [...hosts, host]; +} + +/** Why the shared fields keep a draft from saving, or null; tokens are checked per environment. */ +export function simlockDeviceHostProblem(host: SimlockDeviceHostConfigType): string | null { + const withToken = { ...host, agentToken: "token" }; + if (Option.isSome(parseSimlockDeviceHostDraft(withToken))) return null; + if (!host.label.trim()) return "Give the host a label."; + // With in-range limits, a draft that still fails has a bad gateway URL. + return Option.isNone( + parseSimlockDeviceHostDraft({ ...withToken, maxLeases: 1, maxLeaseMinutes: 60 }), + ) + ? "Enter the gateway's origin, such as https://gateway.example.ts.net:4700." + : "Keep the limits within the ranges shown."; +} + +export function simlockLeaseLimits(host: SimlockDeviceHostConfigType) { + const devices = host.maxLeases === 1 ? "1 device" : `${host.maxLeases} devices`; + return `Up to ${devices} at once, each released after ${host.maxLeaseMinutes} min`; +} + +export interface SimlockHostEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly hosts: ReadonlyArray; +} + +/** A token field: the environment's saved token, a replacement, or none. */ +export type SimlockTokenEntry = + | { kind: "saved" } + | { kind: "new"; value: string } + | { kind: "cleared" }; + +/** + * The host list each environment saves: the shared fields everywhere, and each environment's own + * token. Saved tokens arrive redacted, and sending the redacted value back keeps them. + */ +export function planSimlockDeviceHostSave( + environments: ReadonlyArray, + draft: SimlockDeviceHostConfigType, + tokens: ReadonlyMap, +): + | { readonly problem: string } + | { + readonly patches: ReadonlyArray<{ + environmentId: EnvironmentId; + hosts: ReadonlyArray; + }>; + } { + const shared = simlockDeviceHostProblem(draft); + if (shared) return { problem: shared }; + const patches = []; + for (const environment of environments) { + const saved = environment.hosts.find((host) => host.id === draft.id)?.agentToken ?? ""; + const entry = tokens.get(environment.environmentId) ?? { kind: "cleared" }; + const agentToken = + entry.kind === "saved" ? saved : entry.kind === "new" ? entry.value.trim() : ""; + if (!agentToken) return { problem: `Add an agent token for ${environment.label}.` }; + patches.push({ + environmentId: environment.environmentId, + hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false), + }); + } + return { patches }; +} diff --git a/docs/internals/devices.md b/docs/internals/devices.md index c50cb504a84e7..5273f7942e9e0 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -44,6 +44,27 @@ clients never hold it. Agent config files are written per device device before targeting it. Device settings run `simctl` on the simulator's machine, which EAS does not expose, so they are unavailable there. +## Simlock hosts lease through the agent-device front + +A [Simlock host](../../apps/server/src/device/SimlockDeviceHost.ts) (alpha) opens a +device by running T3's own agent-device CLI against the gateway's agent-device +front (`connect proxy`, then `open`), not with Simlock's lease HTTP API: +agent-device cannot adopt a lease it did not allocate. The lease is bound to +that one agent-device session and to the directory `open` ran in, so +`agentEndpoint` hands every thread the same session, a `stateDir` holding its +connection record, and no `--udid`/`--serial` (the gateway refuses selectors). +Installs work only from that directory; `device_open` names it. + +Simlock serves a lease's screen and input to its holder in serve-sim's formats +on both platforms: AVCC envelopes at `/v1/leases//stream` and serve-sim's +input messages at `/v1/leases//input`, both with the agent token. Clients +therefore treat a Simlock Android lease like iOS (`serveSimOnly` in the stream +client), and `upstream` maps serve-sim's `stream.avcc` and `helper/ws` paths to +those routes. Simlock has no MJPEG, screenshot, or host command routes, so the +panel offers only input there. +Leases are renewed while tracked and released at the host's age limit; one a +crashed server left behind ends on its own one TTL later, so there is no ledger. + ## The hub is never exposed serve-sim has a shell-exec route whose token is readable from its own diff --git a/docs/user/devices.md b/docs/user/devices.md index 42c0db4b27afa..ce5a7d8d06d05 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -22,7 +22,7 @@ Choose **Float device over chat** in the toolbar to keep watching and tapping th device in a small window while the right panel shows something else; drag the window by its handle, resize it from any edge, and use **Open in right panel** to bring it back. -Turn off the device hub in **Settings → Devices** to stop the +Turn off **Use devices** in **Settings → Devices** to stop the helper processes; simulators and emulators keep running until you power them off. @@ -80,12 +80,12 @@ viewer stops streaming and leaves the device available to the agent. Device activity also appears in the thread timeline. Agents drive the device through the `agent-device` command line. T3 -Code installs and starts it only after **Agent device access** is enabled. iOS +Code installs and starts it only after **Agents can use devices** is on. iOS taps build a small test runner on first use, which takes a couple of minutes once per server. Restart an existing agent session after granting access so it receives the device CLI environment. -To keep agents away from simulators, turn off **Agent device access** in +To keep agents away from simulators, turn off **Agents can use devices** in **Settings → Devices**. This hides the device tools from agents started from then on; your own Device panel is unaffected. @@ -99,12 +99,12 @@ still-image stream and Android cannot show video. ## SSH device hosts In Settings → Devices, choose the environments that should use -the host and add it under **Device hosts**. Enter an SSH alias or `user@host`, with +the host and choose **Add source → Machine over SSH**. Enter an SSH alias or `user@host`, with an optional identity file and port. These resolve on the environment server, so use the SSH configuration and keys available there. Password prompts are not supported. -**Test connection** checks SSH, Node, npm, and platform tools without installing +**Test connection**, in the host's menu, checks SSH, Node, npm, and platform tools without installing anything, with a result for each selected environment. Targets that resolve to the environment’s own machine are skipped, since its devices are already local. The first device listing installs pinned device tools on the host. @@ -123,8 +123,45 @@ installation, and connectivity to development servers such as Metro separately. A simulator on another machine cannot reach Metro through your environment's localhost without forwarding or another reachable address. +## Simlock hosts (alpha) + +[Simlock](https://incubator.callstack.com/simlock/) pools iOS simulators and +Android emulators across machines. If you run a Simlock gateway, T3 can lease +devices from it. Simlock is new and unreleased, so this integration is an +alpha: it may change or stop working with a newer Simlock. T3 does not install +or manage Simlock; set up the gateway and its workers yourself. It needs a +gateway and workers built from Simlock's `main` at +[6316e10](https://github.com/callstackincubator/simlock/commit/6316e10) or +later, the first with a lease holder's screen and input. + +Turn on **Settings → Labs → Simlock device hosts (alpha)** and **Use devices** +in Settings → Devices, then choose **Add source → Simlock pool** with an **agent token** +(`simlock --gateway token create --role agent`). Give each T3 server its own +token: Simlock counts every lease made with one token against the same +requester, and allows one at a time unless the gateway's +`lease.maxPerRequester` is raised to the host's **Devices at once**. The token +leases devices and reaches only its own leases, but commands on a leased device +run on its worker, so treat it like shell access to your workers. T3 sends it +with every request, so use an HTTPS gateway URL, or plain HTTP only over an +encrypted network such as Tailscale. The gateway +and workers need `capabilities.stream.enabled` and `capabilities.input.enabled` +for the live screen and for input. + +The picker lists the models your workers can make. Opening one takes a lease; +closing it with **Return device to the pool**, or removing the host, returns it +wiped. The panel keeps a watched device leased up to the host's lease limit. +You can tap, swipe, type, and press Home, and Back and Recents on Android. +Rotation, appearance, text size, device tools, and screenshots are not offered +for Simlock devices; agents can still do them through `device_open`. On iOS 27 +simulators, typing on your keyboard does nothing; tap the on-screen keyboard +instead. Workers must run the same agent-device +version as T3 (see Device tool updates), and agent-device accepts app installs +for a lease only from the folder it was opened from, which `device_open` +names. A device cannot reach development servers on your machine through +localhost; use an address its worker can reach. + ## Device tool updates -The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings → Devices → Check versions reads installed versions without installing tools or starting devices. +The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. In Settings → Devices, select a version and choose **Check versions** to read installed versions without installing tools or starting devices. To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved. diff --git a/packages/client-runtime/src/device/stream.test.ts b/packages/client-runtime/src/device/stream.test.ts index 7da96cb3d75b7..737daf5158c04 100644 --- a/packages/client-runtime/src/device/stream.test.ts +++ b/packages/client-runtime/src/device/stream.test.ts @@ -199,7 +199,11 @@ class MjpegImage extends EventTarget { } } -function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true) { +function recoveryFixture( + platform: "ios" | "android" = "ios", + preferMjpeg = true, + serveSimOnly = false, +) { vi.useFakeTimers(); const sockets: Socket[] = []; class Socket { @@ -211,7 +215,9 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true onclose: ((event: { code: number; reason: string }) => void) | null = null; send = vi.fn(); close = vi.fn(); - constructor() { + readonly url: string; + constructor(url: string) { + this.url = url; sockets.push(this); } } @@ -273,6 +279,7 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true { platform, preferMjpeg, + serveSimOnly, deviceId: "test-device", access: { httpBase: "https://test/api/device-hub", @@ -353,6 +360,56 @@ describe("shared device stream readiness and recovery", () => { } }); + it("streams and drives a serve-sim-only Android device over serve-sim's AVCC and input socket", async () => { + const { client, events, fetch, sockets, videoBodies, decoders } = recoveryFixture( + "android", + false, + true, + ); + client.start(); + await vi.advanceTimersByTimeAsync(0); + // No MJPEG priming: Simlock serves only the AVCC stream and the input socket. + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://test/api/device-hub/vendor/serve-sim/helper/test-device/stream.avcc", + ]); + expect(sockets.map((socket) => socket.url)).toEqual([ + "wss://test/api/device-hub/vendor/serve-sim/helper/ws?device=test-device", + ]); + // Simlock adds meta (5), keep-alive (6), and lease-end (7) frames to serve-sim's envelopes. + videoBodies[0]!.enqueue( + new Uint8Array([ + ...envelope(5, [0x7b, 0x7d]), + ...envelope(1, [1, 0x64, 0, 0x1f]), + ...envelope(6, []), + ...envelope(2, [2]), + ...envelope(7, [0x7b, 0x7d]), + ]), + ); + await vi.advanceTimersByTimeAsync(0); + expect(decoders[0]!.decode).toHaveBeenCalledOnce(); + + const input = sockets[0]!; + input.onmessage?.({ data: Uint8Array.of(0x83).buffer }); + input.onmessage?.({ + data: new Uint8Array([ + 0x82, + ...new TextEncoder().encode('{"width":1080,"height":2400,"orientation":"portrait"}'), + ]).buffer, + }); + expect(events.onInputConnected).toHaveBeenLastCalledWith(true, undefined); + client.sendTouch("begin", 0.25, 0.5); + client.pressButton("back"); + const sent = input.send.mock.calls.map(([payload]) => { + const bytes = payload as Uint8Array; + return [bytes[0], JSON.parse(new TextDecoder().decode(bytes.subarray(1)))]; + }); + expect(sent).toEqual([ + [0x03, { type: "begin", x: 0.25, y: 0.5 }], + [0x04, { button: "back" }], + ]); + client.stop(); + }); + it("does not repaint a delayed JPEG seed over newer decoded video", async () => { const { client, videoBodies, drawImage, decodedFrame } = recoveryFixture("ios", false); let resolveBitmap!: (bitmap: ImageBitmap) => void; diff --git a/packages/client-runtime/src/device/stream.ts b/packages/client-runtime/src/device/stream.ts index dc1720725f4f1..c4b8078597245 100644 --- a/packages/client-runtime/src/device/stream.ts +++ b/packages/client-runtime/src/device/stream.ts @@ -120,6 +120,8 @@ export interface DeviceStreamTarget { readonly preferMjpeg?: boolean; readonly panelId?: 1 | 3; readonly videoOnly?: boolean; + /** The host speaks only serve-sim's AVCC stream and input socket, on both platforms (Simlock). */ + readonly serveSimOnly?: boolean; } export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher"; @@ -388,12 +390,16 @@ export function createDeviceStreamClient( ): DeviceStreamClient { const { access, platform, deviceId } = target; const sink = "present" in output ? output : createCanvasFrameSink(output); - const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; + // Android normally streams over serve-emu's socket; a serve-sim-only host serves it like iOS. + const serveSimOnly = target.serveSimOnly === true; + const avcc = platform === "ios" || serveSimOnly; + const vendor = avcc ? "/vendor/serve-sim" : "/vendor/serve-emu"; const device = encodeURIComponent(deviceId); const httpUrl = (path: string) => withDeviceHubQuery(`${access.httpBase}${vendor}${path}`, access); const wsUrl = (path: string) => withDeviceHubQuery(`${access.wsBase}${vendor}${path}`, access); - const useWebCodecs = isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg); + const useWebCodecs = + isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg && !serveSimOnly); let stopped = true; let socket: WebSocket | null = null; @@ -551,7 +557,7 @@ export function createDeviceStreamClient( const paint = (source: CanvasImageSource, width: number, height: number) => { if (stopped) return; - if (platform === "android" && (screen?.width !== width || screen.height !== height)) { + if (!avcc && (screen?.width !== width || screen.height !== height)) { screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" }; events.onScreen(screen); } @@ -572,7 +578,11 @@ export function createDeviceStreamClient( }; const recoverDecoder = () => { - if (platform === "ios") fallBackToMjpeg(); + if (serveSimOnly) { + // A new AVCC request starts a fresh capture with a keyframe; there is no MJPEG. + closeDecoder(); + controller?.abort(); + } else if (platform === "ios") fallBackToMjpeg(); else { closeDecoder(); connecting("Video decoder restarted."); @@ -585,10 +595,7 @@ export function createDeviceStreamClient( const decoder = new VideoDecoder({ output: (frame) => { try { - if ( - videoDecoder === decoder && - (platform !== "ios" || feedGeneration === videoGeneration) - ) { + if (videoDecoder === decoder && (!avcc || feedGeneration === videoGeneration)) { decodedVideoGeneration = feedGeneration; paint(frame, frame.displayWidth, frame.displayHeight); } @@ -597,11 +604,7 @@ export function createDeviceStreamClient( } }, error: () => { - if ( - stopped || - videoDecoder !== decoder || - (platform === "ios" && feedGeneration !== videoGeneration) - ) + if (stopped || videoDecoder !== decoder || (avcc && feedGeneration !== videoGeneration)) return; recoverDecoder(); }, @@ -638,7 +641,7 @@ export function createDeviceStreamClient( awaitingKeyframe = false; } if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) { - if (platform !== "ios") { + if (!avcc) { recoverDecoder(); return; } @@ -769,7 +772,12 @@ export function createDeviceStreamClient( if (!configured) { await reader.cancel().catch(() => {}); if (!isCurrent()) return; - if (target.videoOnly) + if (serveSimOnly) + setStatus( + "error", + `This browser cannot decode this device's ${avcCodecString(chunk.payload)} stream.`, + ); + else if (target.videoOnly) setStatus( "error", `This browser cannot decode the Duo panel's ${avcCodecString(chunk.payload)} stream.`, @@ -1070,7 +1078,7 @@ export function createDeviceStreamClient( const connectIosInput = async () => { if (stopped) return; const session = generation; - await primeIosHelper(session); + if (!serveSimOnly) await primeIosHelper(session); if (stopped || generation !== session) return; const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`)); ws.binaryType = "arraybuffer"; @@ -1231,7 +1239,11 @@ export function createDeviceStreamClient( iosInputAdmitted = false; iosInputUnavailable = false; connecting(); - if (platform === "ios") { + if (serveSimOnly) { + if (!target.videoOnly) void connectIosInput(); + if (useWebCodecs) void readIosVideo(); + else fail("This browser cannot decode this device's stream (WebCodecs unavailable)."); + } else if (platform === "ios") { if (!target.videoOnly) void connectIosInput(); if (useWebCodecs) void readIosVideo(); else fallBackToMjpeg(); @@ -1275,7 +1287,7 @@ export function createDeviceStreamClient( const send = (payload: Uint8Array | string) => { if ( !stopped && - (platform !== "ios" || (iosInputAdmitted && !iosInputUnavailable)) && + (!avcc || (iosInputAdmitted && !iosInputUnavailable)) && socket?.readyState === WebSocket.OPEN ) socket.send(payload); @@ -1326,7 +1338,7 @@ export function createDeviceStreamClient( startDuoVideo(panels); }, sendTouch: (phase, x, y) => { - if (platform === "ios") { + if (avcc) { send(taggedJson(IOS_MSG_TOUCH, { type: phase, ...rawPoint(x, y) })); return; } @@ -1334,7 +1346,7 @@ export function createDeviceStreamClient( send(JSON.stringify({ type: "touch", action, x, y })); }, sendKey: (event, phase) => { - if (platform === "ios") { + if (avcc) { const usage = hidUsageForCode(event.code); if (usage !== null) send(taggedJson(IOS_MSG_KEY, { type: phase, usage })); return; @@ -1348,7 +1360,7 @@ export function createDeviceStreamClient( } }, sendText: (text) => { - if (platform !== "android") return; + if (avcc) return; let chunk = ""; let chunkBytes = 0; let sentChunks = 0; @@ -1366,15 +1378,17 @@ export function createDeviceStreamClient( if (chunk.length > 0) send(JSON.stringify({ type: "text", text: chunk })); }, pressButton: (button) => { - if (platform === "ios") { + if (avcc) { const name = button === "home" ? "home" - : button === "appSwitcher" + : button === "appSwitcher" || button === "recents" ? "app_switcher" : button === "power" ? "lock" - : null; + : button === "back" && platform === "android" + ? "back" + : null; if (name) send(taggedJson(IOS_MSG_BUTTON, { button: name })); return; } diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts index 6de9f15125d45..e49e54a09fdf5 100644 --- a/packages/contracts/src/device.test.ts +++ b/packages/contracts/src/device.test.ts @@ -4,6 +4,7 @@ import { DeviceBootError, DeviceServiceState, EasDeviceHostConfig, + SimlockDeviceHostConfig, EasDeviceHostConfigs, SshDeviceHostConfigs, } from "./device.ts"; @@ -49,6 +50,7 @@ describe("configured device hosts", () => { it("marks the EAS access token as a secret setting", () => { expect(secretSettingsFields(EasDeviceHostConfig)).toEqual(["accessToken"]); + expect(secretSettingsFields(SimlockDeviceHostConfig)).toEqual(["agentToken"]); }); }); @@ -63,6 +65,7 @@ describe("device state for clients from before EAS hosts", () => { hosts: [ { ...summary, id: "local", kind: "local" as const }, { ...summary, id: "eas", kind: "eas" as const }, + { ...summary, id: "pool", kind: "simlock" as const }, ], hostStatus: "ready" as const, hostStatuses: {}, @@ -74,7 +77,7 @@ describe("device state for clients from before EAS hosts", () => { revision: 1, }; - it("sends EAS hosts where an older client does not look, and reads them back", () => { + it("sends EAS and Simlock hosts where an older client does not look, and reads them back", () => { const wire = encodeState(state); expect(decodeOlderState(wire).hosts.map((host) => host.id)).toEqual(["local"]); expect(decodeState(wire).hosts).toEqual(state.hosts); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index dbb90f6d41e58..2335d6ba8d654 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -68,7 +68,32 @@ export const EasDeviceHostConfig = Schema.Struct({ ); export type EasDeviceHostConfig = typeof EasDeviceHostConfig.Type; -export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig; +/** + * Alpha: devices leased from a Simlock gateway (https://incubator.callstack.com/simlock). + * T3 never installs Simlock; the gateway and its workers are the user's own. + */ +export const SimlockDeviceHostConfig = Schema.Struct({ + kind: Schema.Literal("simlock"), + id: ConfiguredDeviceHostId, + label: TrimmedNonEmptyString, + /** The gateway's origin, such as https://gateway.example.ts.net:4700. */ + gatewayUrl: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/[^\s/]+\/?$/)), + /** Leases, drives, watches, and takes input on its own leases. Kept in the secret store. */ + agentToken: TrimmedString.pipe( + Schema.withDecodingDefaultKey(Effect.succeed("")), + Schema.annotateKey({ settingsSecret: true }), + ), + maxLeases: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(2)), + ), + /** A lease the Device panel keeps alive ends this long after it started. */ + maxLeaseMinutes: Schema.Int.check(Schema.isBetween({ minimum: 5, maximum: 240 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(60)), + ), +}); +export type SimlockDeviceHostConfig = typeof SimlockDeviceHostConfig.Type; + +export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig | SimlockDeviceHostConfig; const uniqueHostIds = Schema.makeFilter( (hosts: ReadonlyArray<{ readonly id: string }>) => @@ -85,6 +110,9 @@ export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(uniq */ export const EasDeviceHostConfigs = Schema.Array(EasDeviceHostConfig).check(uniqueHostIds); +/** Kept apart from the other host lists for the same reason as EAS hosts. */ +export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).check(uniqueHostIds); + export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), @@ -161,7 +189,15 @@ const EasDeviceHostSummary = Schema.Struct({ ...deviceHostSummaryFields, kind: Schema.Literal("eas"), }); -export const DeviceHostSummary = Schema.Union([MachineDeviceHostSummary, EasDeviceHostSummary]); +const SimlockDeviceHostSummary = Schema.Struct({ + ...deviceHostSummaryFields, + kind: Schema.Literal("simlock"), +}); +export const DeviceHostSummary = Schema.Union([ + MachineDeviceHostSummary, + EasDeviceHostSummary, + SimlockDeviceHostSummary, +]); export type DeviceHostSummary = typeof DeviceHostSummary.Type; export const DeviceHostStatus = Schema.Literals([ @@ -198,6 +234,8 @@ const deviceServiceStateFields = { supportsToolInspection: Schema.optional(Schema.Boolean), /** Servers from before EAS hosts ignore `easDeviceHosts`, so clients offer them only when set. */ supportsEasHosts: Schema.optional(Schema.Boolean), + /** Servers from before Simlock hosts ignore `simlockDeviceHosts`. */ + supportsSimlockHosts: Schema.optional(Schema.Boolean), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), hostStatuses: DeviceHostStatuses, @@ -221,6 +259,7 @@ const DeviceServiceStateWire = Schema.Struct({ ...deviceServiceStateFields, hosts: Schema.Array(MachineDeviceHostSummary), easHosts: Schema.optional(Schema.Array(EasDeviceHostSummary)), + simlockHosts: Schema.optional(Schema.Array(SimlockDeviceHostSummary)), }); const DeviceServiceStateDecoded = Schema.Struct({ ...deviceServiceStateFields, @@ -234,18 +273,22 @@ export const DeviceServiceState = DeviceServiceStateWire.pipe( typeof DeviceServiceStateDecoded.Type, typeof DeviceServiceStateWire.Type >({ - decode: ({ easHosts, ...state }) => ({ + decode: ({ easHosts, simlockHosts, ...state }) => ({ ...state, - hosts: [...state.hosts, ...(easHosts ?? [])], + hosts: [...state.hosts, ...(easHosts ?? []), ...(simlockHosts ?? [])], }), encode: (state) => ({ ...state, hosts: state.hosts.filter( - (host): host is typeof MachineDeviceHostSummary.Type => host.kind !== "eas", + (host): host is typeof MachineDeviceHostSummary.Type => + host.kind === "local" || host.kind === "ssh", ), easHosts: state.hosts.filter( (host): host is typeof EasDeviceHostSummary.Type => host.kind === "eas", ), + simlockHosts: state.hosts.filter( + (host): host is typeof SimlockDeviceHostSummary.Type => host.kind === "simlock", + ), }), }), ), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c6a59445d66fa..dda56d86bae60 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,4 @@ -import { EasDeviceHostConfigs, SshDeviceHostConfigs } from "./device.ts"; +import { EasDeviceHostConfigs, SimlockDeviceHostConfigs, SshDeviceHostConfigs } from "./device.ts"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, @@ -1195,6 +1195,9 @@ export const ServerSettings = Schema.Struct({ deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), easDeviceHosts: EasDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + simlockDeviceHosts: SimlockDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Alpha: Simlock device hosts. Off, the server ignores `simlockDeviceHosts`. */ + simlockDeviceHostsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1551,6 +1554,8 @@ export const ServerSettingsPatch = Schema.Struct({ deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), easDeviceHosts: Schema.optionalKey(EasDeviceHostConfigs), + simlockDeviceHosts: Schema.optionalKey(SimlockDeviceHostConfigs), + simlockDeviceHostsEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), compactIdleThreads: Schema.optionalKey(LegacyCompactIdleThreads), idleCompaction: Schema.optionalKey(IdleCompactionMode), From ba18706bcec1968ceea22bad11d8d24a4587fb0f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 28 Oct 2026 12:20:40 -0700 Subject: [PATCH 12/13] fix(mobile): use a native split button for Android merges (#26373) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/mobile/assets/icons/chevron-down.xml | 4 + .../src/components/ui/MenuPopup.android.tsx | 11 +- .../src/components/ui/SplitButton.android.tsx | 145 ++++++ apps/mobile/src/components/ui/SplitButton.tsx | 65 +++ .../src/components/ui/SplitButton.types.ts | 14 + .../PullRequestMergeControls.tsx | 143 ++---- patches/@expo__ui@58.0.20.patch | 472 +++++++++++++++++- pnpm-lock.yaml | 8 +- 8 files changed, 737 insertions(+), 125 deletions(-) create mode 100644 apps/mobile/assets/icons/chevron-down.xml create mode 100644 apps/mobile/src/components/ui/SplitButton.android.tsx create mode 100644 apps/mobile/src/components/ui/SplitButton.tsx create mode 100644 apps/mobile/src/components/ui/SplitButton.types.ts diff --git a/apps/mobile/assets/icons/chevron-down.xml b/apps/mobile/assets/icons/chevron-down.xml new file mode 100644 index 0000000000000..285eda9c1b463 --- /dev/null +++ b/apps/mobile/assets/icons/chevron-down.xml @@ -0,0 +1,4 @@ + + + diff --git a/apps/mobile/src/components/ui/MenuPopup.android.tsx b/apps/mobile/src/components/ui/MenuPopup.android.tsx index b5af18cd2bcbb..dd6ef2b046566 100644 --- a/apps/mobile/src/components/ui/MenuPopup.android.tsx +++ b/apps/mobile/src/components/ui/MenuPopup.android.tsx @@ -55,7 +55,9 @@ function MenuIcon(props: { ); } -export function MenuPopup(props: MenuPopupProps) { +export function MenuPopupItems( + props: Pick, +) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const { scale, menuItemHeight } = useAndroidControlSizing(); const body = resolveScaledTextRole("body", appearance.baseFontSize); @@ -64,7 +66,7 @@ export function MenuPopup(props: MenuPopupProps) { const muted = colors["--color-foreground-muted"]; // A fixed native item height clips wrapped labels; a minimum lets each row grow. const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })]; - const items = ( + return ( <> {props.parent ? ( @@ -146,6 +148,11 @@ export function MenuPopup(props: MenuPopupProps) { ))} ); +} + +export function MenuPopup(props: MenuPopupProps) { + const { themeVariables: colors } = useAppearancePreferences(); + const items = ; if (props.inline) { return ( ; + const enabled = !props.disabled && !props.loading; + const menuEnabled = props.actions.some((action) => !action.attributes?.disabled); + const buttonColors = { + containerColor: colors["--color-primary"], + contentColor: colors["--color-primary-foreground"], + disabledContainerColor: colors["--color-subtle-strong"], + disabledContentColor: colors["--color-foreground-muted"], + }; + // A usable menu beside a disabled action still takes the disabled palette, so the pair reads as one control. + const trailingColors = enabled + ? buttonColors + : { + ...buttonColors, + containerColor: buttonColors.disabledContainerColor, + contentColor: buttonColors.disabledContentColor, + }; + const label = ( + + {props.loading ? ( + <> + + + + ) : props.icon ? ( + <> + {/* The glyph host consumes touches, so it runs the action itself. */} + + + + + + + + ) : null} + {props.label} + + ); + return ( + + + + {label} + + + setExpanded(false)} + color={colors["--color-card-alt"]} + > + + + + + { + setExpanded(false); + if (action.id !== undefined) props.onPressAction(action.id); + }} + onBack={() => {}} + /> + + + + + + ); +} diff --git a/apps/mobile/src/components/ui/SplitButton.tsx b/apps/mobile/src/components/ui/SplitButton.tsx new file mode 100644 index 0000000000000..511c3a5b7c29f --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.tsx @@ -0,0 +1,65 @@ +import { Pressable, View } from "react-native"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { Menu } from "./Menu"; +import type { SplitButtonProps } from "./SplitButton.types"; + +export function SplitButton(props: SplitButtonProps) { + const active = !props.disabled; + const menuEnabled = props.actions.some((action) => !action.attributes?.disabled); + return ( + + + {props.loading ? ( + + ) : props.icon ? ( + + ) : null} + + {props.label} + + + {props.actions.length > 0 ? ( + + props.onPressAction(nativeEvent.event)} + > + + + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/SplitButton.types.ts b/apps/mobile/src/components/ui/SplitButton.types.ts new file mode 100644 index 0000000000000..73d0b36f2ce20 --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.types.ts @@ -0,0 +1,14 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { AppSymbolName } from "./AppSymbol"; + +export interface SplitButtonProps { + readonly label: string; + readonly accessibilityLabel?: string; + readonly icon?: AppSymbolName; + readonly disabled?: boolean; + readonly loading?: boolean; + readonly onPress: () => void; + readonly menuTitle: string; + readonly actions: MenuAction[]; + readonly onPressAction: (id: string) => void; +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index b784bdb4eb427..8ba68d2a2bfa9 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -1,4 +1,3 @@ -import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -6,12 +5,10 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; -import { SymbolView } from "../../components/ui/AppSymbol"; -import { Menu } from "../../components/ui/Menu"; import { Button } from "../../components/ui/Button"; -import { IconButton } from "../../components/ui/IconButton"; +import { SplitButton } from "../../components/ui/SplitButton"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -26,8 +23,8 @@ import { import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequestStack"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; -import type { PullRequestStackState } from "./usePullRequestStack"; import { PullRequestGlyph } from "./pullRequestGlyphs"; +import type { PullRequestStackState } from "./usePullRequestStack"; export function PullRequestMergeControls(props: { readonly pr: PullRequestDetail; @@ -169,107 +166,41 @@ export function PullRequestMergeControls(props: { }; return ( - - {Platform.OS === "android" ? ( - -