diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 66be80cd12312..f48bb0377ad94 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -38,8 +38,8 @@ This installs the dev client for the checkout's native fingerprint: from the device, the local cache, or the shared cache in seconds, building once only when no machine has that fingerprint yet (5-15 minutes, logged to the file it names). The device id may be omitted when exactly one device is booted. It -prints one JSON line; on failure it names the next command. Authorized mobile -verification includes that step unless the user prohibits it. +prints one JSON line; on failure it names the next command. Mobile verification +includes that step unless the user prohibits it. For a device you opened in an agent-device session elsewhere (a device pool, cloud, or remote host), run `vp run mobile:install --session ` diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 353a1f41a2616..1c4ae817060a9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -59,6 +59,7 @@ github:myacoub91 github:naMqe-h github:nateEc github:naveed949 +github:nexxeln github:nmggithub github:Noojuno github:notkainoa diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml index 9396d8e3e7a63..41efb7b69a5a2 100644 --- a/.github/workflows/agent-context-budget.yml +++ b/.github/workflows/agent-context-budget.yml @@ -51,5 +51,5 @@ jobs: echo "Accepted by the agent-context-increase label." exit 0 fi - echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text, or ask a maintainer to add the agent-context-increase label." + echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text and rerun the evals that cover it (evals/README.md), or ask a maintainer to add the agent-context-increase label." exit 1 diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml new file mode 100644 index 0000000000000..45e4c9f2ad42d --- /dev/null +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -0,0 +1,189 @@ +name: Mobile Fingerprint Label + +# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code +# (fork PRs included) with a read-only token and records which platforms' +# fingerprints changed. This one runs from main with a write token and syncs +# the "📱 Native Change" label. It never checks out or runs PR code. +# +# A PR can edit the check workflow it runs under, so the recorded result is +# untrusted data: it is validated, and at worst a PR mislabels itself. + +on: + workflow_run: + workflows: [Mobile Fingerprint Check] + types: [completed] + +permissions: + contents: read + +jobs: + label: + name: Sync native change label + # A cancelled run was superseded by a newer push, whose run labels instead. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: read + issues: write + pull-requests: write + steps: + - id: pr + name: Resolve the pull request behind the check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const results = artifacts.filter( + (artifact) => artifact.name === "fingerprint-result" && !artifact.expired, + ); + if (results.length !== 1) { + core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`); + return; + } + + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch. + // Requiring the PR's current head to be the checked commit keeps a + // slow run for an older push from overwriting a newer result. + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`); + return; + } + + core.setOutput("artifact_id", String(results[0].id)); + core.setOutput("pr_number", String(matching[0].number)); + + # Read the archive as bytes; never extract PR-produced files into a + # workspace this job runs code from. + - id: result + name: Read changed platforms + if: steps.pr.outputs.pr_number != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip" + changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)" + if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then + echo "Unexpected changed-platforms value '$changed'." >&2 + exit 1 + fi + echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + if: steps.pr.outputs.pr_number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "📱 Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = Number(process.env.PR_NUMBER); + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + ); diff --git a/AGENTS.md b/AGENTS.md index cff91bdc1d2be..27b3f5221458a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Verifying -- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, tests beside changes, and the cross-file checks those miss. CI still runs the full test suites. - While you iterate, run `vp test run `. For big files, add `-t ""`. - Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. @@ -135,11 +135,11 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real }); ``` -- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. +- User-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. - Real agent turns cost money. To watch a turn stream through the pipeline and UI, use the free `Replay (dev)` provider that `vp run dev` registers; [test-t3-app](.agents/skills/test-t3-app/SKILL.md) shows how to start one. Use a real provider only when the change depends on that provider's behavior, and then pick its cheapest model, such as Haiku for Claude or Luna for Codex. -For authorized mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. +For mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. ## Evals @@ -215,5 +215,4 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ ## Additional tips -- Don't verify with browsers or computer use unless the user explicitly agrees or requests it. - Security is important, but should not be over-indexed on, especially for dev mode/maintainer-only features. diff --git a/README.md b/README.md index 36f558a79dc60..6f6dab985e154 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ yay -S t3code-nightly-bin The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). +#### Nix (nixpkgs) + +```bash +nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code +``` + +The package is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code) +and includes the desktop app (`t3code-desktop`) and the `t3` command. +See [Nix installation](./docs/user/install.md#nix-nixpkgs) for NixOS configuration and updates. + ## Some notes We are very very early in this project. Expect bugs. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 728ab36f7fcb2..886ccf3cd798f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.3", + "version": "0.1.5", "private": true, "type": "module", "main": "dist-electron/boot.cjs", @@ -22,7 +22,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.9.3", + "electron": "44.10.0", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", diff --git a/apps/desktop/src/coil/updateDelivery/UpdateDelivery.ts b/apps/desktop/src/coil/updateDelivery/UpdateDelivery.ts index 24b4a206e86fb..ea1ec4df4a184 100644 --- a/apps/desktop/src/coil/updateDelivery/UpdateDelivery.ts +++ b/apps/desktop/src/coil/updateDelivery/UpdateDelivery.ts @@ -93,7 +93,7 @@ type DeliveryServices = | FileSystem.FileSystem | HttpClient.HttpClient; -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const electronWindow = yield* ElectronWindow.ElectronWindow; diff --git a/apps/desktop/src/coil/updateDelivery/connectionHealth.ts b/apps/desktop/src/coil/updateDelivery/connectionHealth.ts index a51d0b44506b6..2e150093ce780 100644 --- a/apps/desktop/src/coil/updateDelivery/connectionHealth.ts +++ b/apps/desktop/src/coil/updateDelivery/connectionHealth.ts @@ -26,7 +26,7 @@ export const WATCHDOG_TIMEOUT_MS = SERVER_HEARTBEAT_INTERVAL_MS * 2.5; export const FLOOR_POLL_INTERVAL_MS = 15 * 60_000; /** Reconnect backoff. Capped low because the relay closes streams every 15 min by design. */ -export const RECONNECT_BASE_DELAY_MS = 1_000; +const RECONNECT_BASE_DELAY_MS = 1_000; export const RECONNECT_MAX_DELAY_MS = 60_000; export type ConnectionVerdict = diff --git a/apps/desktop/src/coil/updateDelivery/decision.ts b/apps/desktop/src/coil/updateDelivery/decision.ts index c6748a130dd40..2076498fd760c 100644 --- a/apps/desktop/src/coil/updateDelivery/decision.ts +++ b/apps/desktop/src/coil/updateDelivery/decision.ts @@ -19,7 +19,7 @@ import { * fails during a build. It must never be treated as a commit hash — an app that believes its own * commit is "unknown" differs from every real manifest and would update on every payload, forever. */ -export const UNKNOWN_COMMIT_HASH = "unknown"; +const UNKNOWN_COMMIT_HASH = "unknown"; export interface InstalledBuild { /** From `DesktopAppIdentity`. Absent for any build that has no embedded hash. */ diff --git a/apps/desktop/src/coil/updateDelivery/manifest.ts b/apps/desktop/src/coil/updateDelivery/manifest.ts index c68922f9c6122..dc44e03abac46 100644 --- a/apps/desktop/src/coil/updateDelivery/manifest.ts +++ b/apps/desktop/src/coil/updateDelivery/manifest.ts @@ -57,7 +57,6 @@ export const UpdateManifest = Schema.Struct({ }); export type UpdateManifest = typeof UpdateManifest.Type; -export const decodeUpdateManifest = Schema.decodeUnknownEffect(UpdateManifest); export const decodeUpdateManifestJson = Schema.decodeEffect(Schema.fromJsonString(UpdateManifest)); export function findAssetForPlatform( diff --git a/apps/desktop/src/coil/updateDelivery/restart.ts b/apps/desktop/src/coil/updateDelivery/restart.ts index c15b1337ed058..a5ffb73b27604 100644 --- a/apps/desktop/src/coil/updateDelivery/restart.ts +++ b/apps/desktop/src/coil/updateDelivery/restart.ts @@ -18,7 +18,7 @@ import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; * enough that a hung one costs the user ten seconds rather than their whole session. The relaunch * is armed before the timer starts, so a forced exit still comes back up. */ -export const SHUTDOWN_GRACE = Duration.seconds(10); +const SHUTDOWN_GRACE = Duration.seconds(10); export type RestartOutcome = "graceful" | "forced-after-timeout"; diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index b2212609030d1..ca85ee8b0c417 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -23,11 +23,14 @@ export const getUpdateState = DesktopIpc.makeIpcMethod({ export const setUpdateChannel = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, - payload: DesktopUpdateChannelSchema, + payload: Schema.Struct({ + channel: DesktopUpdateChannelSchema, + minimumVersion: Schema.optional(Schema.String), + }), result: DesktopUpdateStateSchema, - handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { + handler: Effect.fn("desktop.ipc.updates.setChannel")(function* ({ channel, minimumVersion }) { const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.setChannel(channel); + return yield* updates.setChannel(channel, { minimumVersion }); }), }); @@ -43,11 +46,14 @@ export const downloadUpdate = DesktopIpc.makeIpcMethod({ export const installUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_INSTALL_CHANNEL, - payload: Schema.Void, + /** A version installs only that downloaded build, never one that replaced it. */ + payload: Schema.UndefinedOr(Schema.String), result: DesktopUpdateActionResultSchema, - handler: Effect.fn("desktop.ipc.updates.install")(function* () { + handler: Effect.fn("desktop.ipc.updates.install")(function* (expectedVersion) { const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.install; + if (expectedVersion === undefined) return yield* updates.install; + const { accepted, completed, state } = yield* updates.installPrepared(expectedVersion); + return { accepted, completed, state }; }), }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d4e64148bb69e..571fb32dff636 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -278,11 +278,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), - setUpdateChannel: (channel) => - ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), + setUpdateChannel: (channel, minimumVersion) => + ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, { channel, minimumVersion }), checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), - installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + installUpdate: (expectedVersion) => + ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL, expectedVersion), cliCommand: { getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 5767057b4a35d..a0624ced669ea 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -22,7 +22,7 @@ import { normalizeLinuxPasswordStorePreference, type LinuxPasswordStorePreference, } from "../linuxSecretStorage.ts"; -import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { @@ -180,7 +180,7 @@ export class DesktopAppSettings extends Context.Service< export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { return { ...DEFAULT_DESKTOP_SETTINGS, - updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + updateChannel: desktopUpdateChannelOf(appVersion), }; } diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea351b6ceb2f4..ec5062f9105bf 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -64,6 +64,8 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, boringSidebarMultilineRows: false, + threadDetailsUsageEnabled: false, + threadDetailsItems: {}, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 253a015d8ec9e..1f5321148b77b 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -119,6 +119,239 @@ describe("DesktopRemoteUpdates", () => { ); }); + it.effect("switches to the requested track before preparing its release", () => { + // In-memory settings keep the channel write off real disk I/O. + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-switch"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + assert.equal((yield* updates.getState).channel, "nightly"); + + harness.emit("update-available", { version: "1.2.4-nightly.20261027.1" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); + yield* settle; + + const terminals = terminalReports(reports); + assert.deepEqual( + terminals.map((report) => [report.requestId, report.outcome]), + [["req-switch", "ready-to-install"]], + ); + assert.equal(terminals[0]?.state.downloadedVersion, "1.2.4-nightly.20261027.1"); + }), + ); + }); + + it.effect( + "refuses a crossing whose feed has only an older build, and never offers it later", + () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const older = "1.2.4-nightly.20261020.1"; + yield* Queue.offer(requests, { + ...request("req-older"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + // Answers the track change's check, then the request's own check. + harness.emit("update-available", { version: older }); + yield* settle; + harness.emit("update-available", { version: older }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.4-nightly.20261027.1."]], + ); + // The saved track is the user's intent; a retry picks it up from here. + assert.equal((yield* updates.getState).channel, "nightly"); + + // A plain update afterwards cannot reach the build the switch refused. + yield* Queue.offer(requests, request("req-plain")); + yield* settle; + harness.emit("update-available", { version: older }); + yield* settle; + assert.isNull((yield* updates.getState).availableVersion); + assert.equal(harness.downloadCount(), 0); + }), + ); + }, + ); + + it.effect("only saves the track on a host already running the release", () => { + const harness = makeHarness({ savedChannel: "nightly" }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-track-only"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + // Even with a newer build on the feed, the host only saves its track. + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["up-to-date"], + ); + assert.equal((yield* updates.getState).channel, "latest"); + assert.equal(harness.downloadCount(), 0); + }), + ); + }); + + it.effect("drops a retained download below the switch's checked release", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + // Downloaded earlier on this Stable host, before the switch checked 1.2.5. + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.equal((yield* updates.getState).downloadedVersion, "1.2.4"); + + yield* Queue.offer(requests, { + ...request("req-floor"), + channel: "latest", + targetVersion: "1.2.5", + }); + yield* settle; + harness.emit("update-available", { version: "1.2.5" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.5" }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [ + report.outcome, + report.state.downloadedVersion, + ]), + [["ready-to-install", "1.2.5"]], + ); + }), + ); + }); + + it.effect("refuses to cross tracks remotely while WSL backends need their own checks", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void, wslBackend: true }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-wsl"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["failed"], + ); + assert.include(terminalReports(reports)[0]?.reason ?? "", "Switch it on that machine"); + assert.equal((yield* updates.getState).channel, "latest"); + }), + ); + }); + + it.effect("fails a switch that leaves a same-track host below the checked release", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-behind"), + channel: "latest", + targetVersion: "1.2.5", + }); + yield* settle; + // The feed lags npm and has nothing newer than this host's 1.2.3. + harness.emit("update-not-available"); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.5."]], + ); + }), + ); + }); + + it.effect("fails a crossing to an older-numbered Stable that the feed does not offer", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-to-stable"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + + // Still on Nightly, though the target is numbered below this build. + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.3."]], + ); + }), + ); + }); + + it.effect("accepts a newer build than the checked release on the requested track", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const newer = "1.2.4-nightly.20261028.2"; + yield* Queue.offer(requests, { + ...request("req-newer"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + harness.emit("update-available", { version: newer }); + yield* settle; + harness.emit("update-downloaded", { version: newer }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [ + report.outcome, + report.state.downloadedVersion, + ]), + [["ready-to-install", newer]], + ); + }), + ); + }); + it.effect("reports a failed outcome when quitAndInstall fails", () => { const harness = makeHarness({ quitAndInstall: Effect.fail( @@ -525,6 +758,42 @@ describe("DesktopRemoteUpdates", () => { ); }); + it.effect("cancels a track switch while its own feed check is still running", () => { + const releaseCheck = Deferred.makeUnsafe(); + const harness = makeHarness({ + beforeSetUpdateChannel: Effect.void, + checkForUpdates: Deferred.await(releaseCheck), + }); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-switch-cancel"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-switch-cancel", + }); + yield* settle; + // The next request starts without waiting for the cancelled switch's feed check. + yield* Queue.offer(requests, { + ...request("req-switch-back"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + + assert.equal((yield* updates.getState).channel, "latest"); + yield* Deferred.succeed(releaseCheck, undefined); + }), + ); + }); + it.effect("cancels an active preparation so the next request can run", () => { const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index cb606d8fbd253..560e2e78d4bb1 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -10,10 +10,13 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { @@ -44,9 +47,13 @@ type CommitClaim = export const listen: Effect.Effect< void, never, - DesktopUpdates.DesktopUpdates | DesktopTelemetryPublisher.DesktopTelemetryPublisher | Scope.Scope + | DesktopUpdates.DesktopUpdates + | DesktopTelemetryPublisher.DesktopTelemetryPublisher + | DesktopBackendPool.DesktopBackendPool + | Scope.Scope > = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + const backends = yield* DesktopBackendPool.DesktopBackendPool; const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; const activeRequestIdRef = yield* Ref.make(Option.none()); const requestControlRef = yield* Ref.make<{ @@ -171,7 +178,59 @@ export const listen: Effect.Effect< return; } yield* Ref.set(activeRequestIdRef, Option.some(request.requestId)); - yield* logInfo("remote update requested", { requestId: request.requestId }); + yield* logInfo("remote update requested", { + requestId: request.requestId, + channel: request.channel, + }); + // A switch saves its track first, so the step machine never sees the old track's offer. + // The saved track is the user's intent and stays even if this request fails: a track saved + // toward Stable never offers an older build outside a guarded switch. + if (request.channel !== undefined) { + // A reinstall restarts every backend, but the requesting server checked only its own + // database. A WSL backend keeps its own, so cross tracks only where all can be checked. + const current = yield* updates.getState; + if ( + desktopUpdateChannelOf(current.currentVersion) !== request.channel && + (yield* backends.list).length > 1 + ) { + yield* publishReport( + current, + { + outcome: "failed", + reason: + "This desktop app also runs WSL backends, whose databases a remote switch cannot check. Switch it on that machine.", + }, + request.requestId, + ); + return; + } + const switched = yield* Effect.raceFirst( + updates.setChannel(request.channel, { minimumVersion: request.targetVersion }).pipe( + Effect.as(true), + Effect.catch((error) => + updates.getState.pipe( + Effect.flatMap((state) => + publishReport( + state, + { outcome: "failed", reason: error.message }, + request.requestId, + ), + ), + Effect.as(false), + ), + ), + ), + Deferred.await(cancellation).pipe(Effect.as(false)), + ); + if (!switched) return; + // A host already running the target only needed its track saved; installing a newer + // build here would restart it without the dialog having said so. + const state = yield* updates.getState; + if (request.targetVersion === state.currentVersion) { + yield* publishReport(state, { outcome: "up-to-date" }, request.requestId); + return; + } + } const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; @@ -184,6 +243,12 @@ export const listen: Effect.Effect< Effect.forkScoped, ); + // A host leaving its build's track may need an older build, down to the checked release; + // the updater offers nothing below it. + const crossing = (state: DesktopUpdateState) => + request.channel !== undefined && + request.targetVersion !== undefined && + desktopUpdateChannelOf(state.currentVersion) !== request.channel; const step = Effect.fn("DesktopRemoteUpdates.step")(function* ( state: DesktopUpdateState, ): Effect.fn.Return { @@ -193,7 +258,7 @@ export const listen: Effect.Effect< return false; case "check": attempts = { ...attempts, checks: attempts.checks + 1 }; - yield* updates.check("remote-update").pipe( + yield* updates.check("remote-update", { allowDowngrade: crossing(state) }).pipe( Effect.flatMap((result) => { if (result.checked) return Effect.void; attempts = { ...attempts, checks: attempts.checks - 1 }; @@ -246,21 +311,34 @@ export const listen: Effect.Effect< yield* logInfo("remote update prepared", { requestId: request.requestId }); return true; } - case "done": + case "done": { + // A switch that leaves the host on its old track, or below its checked release, did + // not reach it; a track-only host already at the release is done. + const done = + request.targetVersion !== undefined && + next.outcome === "up-to-date" && + (crossing(state) || + compareSemverVersions(state.currentVersion, request.targetVersion) < 0) + ? { + outcome: "failed" as const, + reason: `The desktop app found no build of ${request.targetVersion}.`, + } + : next; yield* publishReport( state, { - outcome: next.outcome, - ...(next.reason === undefined ? {} : { reason: next.reason }), + outcome: done.outcome, + ...(done.reason === undefined ? {} : { reason: done.reason }), }, request.requestId, ); yield* logInfo("remote update finished", { requestId: request.requestId, - outcome: next.outcome, - reason: next.reason ?? null, + outcome: done.outcome, + reason: done.reason ?? null, }); return true; + } } }); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index d2f24143fc209..c6bcd46b8c206 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -694,6 +695,65 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("ignores a finished download from a track it no longer follows", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + // A backed-out switch's download keeps reporting progress until it finishes. + harness.emit("download-progress", { percent: 40 }); + yield* flushCallbacks; + harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.downloadedVersion, null); + assert.notEqual(state.status, "downloading"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("offers older releases only to checks that leave the running build's track", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const before = harness.checkAllowedDowngrade.length; + yield* updates.setChannel("nightly"); + yield* updates.setChannel("latest"); + yield* updates.check("remote-update", { allowDowngrade: true }); + + // Back on its own Stable track the build never needs an older release. + assert.deepEqual(harness.checkAllowedDowngrade.slice(before), [true, false, true]); + assert.isFalse(harness.allowDowngrade()); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("re-checks a saved track that differs from the running build's track", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.setChannel("latest"); + const checks = harness.checkCount(); + + yield* updates.setChannel("latest"); + + assert.equal(harness.checkCount(), checks + 1); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("does not persist an unchanged update channel as a user preference", () => { const harness = makeHarness(); @@ -713,6 +773,116 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("offers nothing below a track switch's checked release", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.setChannel("latest", { minimumVersion: "1.2.3" }); + + harness.emit("update-available", { version: "1.2.2" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).availableVersion); + harness.emit("update-downloaded", { version: "1.2.2" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).downloadedVersion); + + harness.emit("update-available", { version: "1.2.3" }); + yield* flushCallbacks; + assert.equal((yield* updates.getState).availableVersion, "1.2.3"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps a saved Stable track across a restart without offering an older release", () => { + // A Nightly build whose switch to Stable did not finish before the app quit. + const harness = makeHarness({ appVersion: "1.2.4-nightly.20261027.1", savedChannel: "latest" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("startup"); + + assert.equal((yield* updates.getState).channel, "latest"); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + // Semver ranks Stable 1.2.4 above this Nightly, but a Nightly built before the release's + // version bump can hold migrations 1.2.4 lacks. Crossing needs a guarded switch. + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).availableVersion); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps a saved Nightly track across a restart without crossing on its own", () => { + // A Stable build whose switch to Nightly was refused before the app quit; its floor is gone. + const harness = makeHarness({ savedChannel: "nightly" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("startup"); + harness.emit("update-available", { version: "1.2.4-nightly.20261027.1" }); + yield* flushCallbacks; + + assert.equal((yield* updates.getState).channel, "nightly"); + assert.isNull((yield* updates.getState).availableVersion); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("never offers an older Nightly to a Nightly build's own checks", () => { + const harness = makeHarness({ appVersion: "1.2.4-nightly.20261027.1" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("poll"); + + assert.equal((yield* updates.getState).channel, "nightly"); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("waits out a background check instead of refusing a track change", () => + Effect.gen(function* () { + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const harness = makeHarness({ + beforeSetUpdateChannel: Effect.void, + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCheck)), + ), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("poll").pipe(Effect.forkScoped); + yield* Deferred.await(checkStarted); + + const channelFiber = yield* updates.setChannel("nightly").pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseCheck, undefined); + yield* TestClock.adjust(Duration.seconds(2)); + const state = yield* Fiber.join(channelFiber); + + assert.equal(state.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + it.effect("rejects checks while an update channel change is being persisted", () => Effect.gen(function* () { const channelChangeStarted = yield* Deferred.make(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aeda73f2bfad3..3a0b41cd9e369 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -19,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -34,7 +35,8 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -49,6 +51,8 @@ import { resolveNotAppliedInstallVersion, } from "./updateMachine.ts"; +const CHANNEL_CHANGE_RETRY_DELAY = Duration.seconds(1); +const CHANNEL_CHANGE_RETRIES = 30; const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); @@ -66,6 +70,7 @@ const UpdateInfo = Schema.Struct({ version: Schema.String, // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), + releaseDate: Schema.optional(Schema.String), }); const DownloadProgressInfo = Schema.Struct({ @@ -179,10 +184,16 @@ export class DesktopUpdates extends Context.Service< readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; readonly configure: Effect.Effect; + /** `minimumVersion` is a track switch's checked release; nothing older is offered after. */ readonly setChannel: ( channel: DesktopUpdateChannel, + options?: { readonly minimumVersion?: string | undefined }, ) => Effect.Effect; - readonly check: (reason: string) => Effect.Effect; + /** `allowDowngrade` is for a guarded track switch, which checked that older release. */ + readonly check: ( + reason: string, + options?: { readonly allowDowngrade?: boolean }, + ) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; readonly installPrepared: ( @@ -351,6 +362,20 @@ export const make = Effect.gen(function* () { const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const notAppliedVersionRef = yield* Ref.make(Option.none()); + // The release a track switch's database check cleared. A build crosses to another track only + // through such a switch, and never below that release: version order alone cannot tell whether + // a Stable knows a Nightly's migrations. + const releaseFloorRef = yield* Ref.make(Option.none()); + const offerable = (version: string, state: DesktopUpdateState) => + Ref.get(releaseFloorRef).pipe( + Effect.map( + (floor) => + desktopUpdateChannelOf(version) === state.channel && + (Option.isSome(floor) + ? compareSemverVersions(version, floor.value) >= 0 + : desktopUpdateChannelOf(environment.appVersion) === state.channel), + ), + ); const updateStateRef = yield* Ref.make( createInitialDesktopUpdateState( environment.appVersion, @@ -454,12 +479,13 @@ export const make = Effect.gen(function* () { const allowsPrerelease = channel === "nightly"; yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); - yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + // An older build can lack migrations this one ran. Only a guarded track switch's own check + // may offer one. + yield* electronUpdater.setAllowDowngrade(false); yield* electronUpdater.setFullChangelog(allowsPrerelease); yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease, - allowDowngrade: allowsPrerelease, fullChangelog: allowsPrerelease, }); }); @@ -467,6 +493,7 @@ export const make = Effect.gen(function* () { const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( reason: string, actionReservation: "acquire" | "held" = "acquire", + allowDowngrade = false, ) { yield* Effect.annotateCurrentSpan({ reason }); if (yield* Ref.get(desktopState.quitting)) return false; @@ -488,7 +515,11 @@ export const make = Effect.gen(function* () { yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); yield* logUpdaterInfo("checking for updates", { reason }); + // The flag is shared updater state, so it flips only while this check holds the reservation. + const restoreDowngrade = yield* electronUpdater.allowDowngrade; + if (allowDowngrade) yield* electronUpdater.setAllowDowngrade(true); return yield* electronUpdater.checkForUpdates.pipe( + Effect.ensuring(electronUpdater.setAllowDowngrade(restoreDowngrade).pipe(Effect.ignore)), Effect.as(true), Effect.catchTags({ ElectronUpdaterCheckForUpdatesError: Effect.fn( @@ -849,8 +880,8 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { const state = yield* Ref.get(updateStateRef); - if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { - yield* logUpdaterInfo("ignoring update that does not match selected channel", { + if (!(yield* offerable(info.version, state))) { + yield* logUpdaterInfo("ignoring update outside the selected channel or its floor", { version: info.version, channel: state.channel, }); @@ -865,6 +896,7 @@ export const make = Effect.gen(function* () { info.releaseNotes, info.version, state.channel, + info.releaseDate, ); yield* setState( reduceDesktopUpdateStateOnUpdateAvailable( @@ -986,6 +1018,20 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { const state = yield* Ref.get(updateStateRef); + // A download can finish after its track switch moved on; never offer that build. + if (!(yield* offerable(info.version, state))) { + yield* logUpdaterInfo("ignoring download outside the selected channel or its floor", { + version: info.version, + channel: state.channel, + }); + // Its progress events left the updater "downloading", which would block every check. + if (state.status === "downloading") { + yield* setState( + reduceDesktopUpdateStateOnNoUpdate(state, yield* currentIsoTimestamp), + ); + } + return; + } yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); }), @@ -1092,9 +1138,17 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.updates.configure")), setChannel: Effect.fn("desktop.updates.setChannel")(function* ( nextChannel: DesktopUpdateChannel, + options?: { readonly minimumVersion?: string | undefined }, ) { yield* Effect.annotateCurrentSpan({ channel: nextChannel }); - const activeAction = yield* tryStartChannelChange; + // A background check holds the updater only briefly, so wait it out instead of refusing. + const activeAction = yield* tryStartChannelChange.pipe( + Effect.repeat({ + while: (active) => Option.isSome(active) && active.value === "check", + schedule: Schedule.spaced(CHANNEL_CHANGE_RETRY_DELAY), + times: CHANNEL_CHANGE_RETRIES, + }), + ); if (Option.isSome(activeAction)) { return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value === "install-recovery" ? "install" : activeAction.value, @@ -1103,8 +1157,16 @@ export const make = Effect.gen(function* () { } return yield* Effect.gen(function* () { + yield* Ref.set(releaseFloorRef, Option.fromNullishOr(options?.minimumVersion)); const state = yield* Ref.get(updateStateRef); - if (nextChannel === state.channel) { + const retained = state.downloadedVersion ?? state.availableVersion; + // A build from another track re-checks, so retrying a track switch finds an older release. + // So does a retained offer below the new floor, which must not install. + if ( + nextChannel === state.channel && + nextChannel === desktopUpdateChannelOf(environment.appVersion) && + (retained === null || (yield* offerable(retained, state))) + ) { return state; } @@ -1124,15 +1186,19 @@ export const make = Effect.gen(function* () { } yield* applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = yield* electronUpdater.allowDowngrade; - yield* electronUpdater.setAllowDowngrade(true); - yield* checkForUpdates("channel-change", "held").pipe( - Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), + // Only leaving the running build's track can need an older release. + yield* checkForUpdates( + "channel-change", + "held", + nextChannel !== desktopUpdateChannelOf(environment.appVersion), ); return yield* Ref.get(updateStateRef); }).pipe(Effect.ensuring(finishUpdateAction("channel"))); }), - check: Effect.fn("desktop.updates.check")(function* (reason: string) { + check: Effect.fn("desktop.updates.check")(function* ( + reason: string, + options?: { readonly allowDowngrade?: boolean }, + ) { yield* Effect.annotateCurrentSpan({ reason }); if (!(yield* Ref.get(updaterConfiguredRef))) { return { @@ -1140,7 +1206,7 @@ export const make = Effect.gen(function* () { state: yield* Ref.get(updateStateRef), }; } - const checked = yield* checkForUpdates(reason); + const checked = yield* checkForUpdates(reason, "acquire", options?.allowDowngrade === true); return { checked, state: yield* Ref.get(updateStateRef), diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 028e42fa6e3b7..d88838439dba7 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -28,6 +28,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { releaseNotes: [ { version: "0.0.36-nightly.20260828.1213", + releasedAt: "2026-08-28", items: [ "fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", "fix(web): keep long task drawers usable on small screens by @human in #8313", @@ -60,6 +61,37 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); + it("reads a generated summary as a note and skips the folded full list", () => { + const fixes = Array.from({ length: 9 }, (_, index) => `- Fix ${index + 1} (#${index + 1})`); + const result = normalizeDesktopUpdateReleaseNotes( + [ + "**v1.2.4** · 12 changes from 3 contributors", + "## Highlights", + "Link machines (beta).", + "## Fixes", + ...fixes, + "
", + "Full changelog", + "* fix: raw entry by @human in https://github.com/pingdotgg/t3code/pull/1", + "
", + ].join("\n"), + "1.2.4", + "latest", + "2026-10-29T10:00:00.000Z", + ); + + expect(result.releaseNotes).toEqual([ + { + version: "1.2.4", + releasedAt: "2026-10-29T10:00:00.000Z", + stats: "12 changes from 3 contributors", + intro: "Link machines (beta).", + items: fixes.map((_, index) => `Fix ${index + 1}`), + totalItems: 10, + }, + ]); + }); + it("does not count Markdown or HTML section headings as changes", () => { const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); const result = normalizeDesktopUpdateReleaseNotes( @@ -80,6 +112,35 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ]); }); + it("reads handwritten notes with their own
as a list", () => { + const result = normalizeDesktopUpdateReleaseNotes( + "- Older fix\n- Newer fix\n
Logs\n- hidden\n
", + "1.2.3", + "latest", + ); + + expect(result.releaseNotes).toEqual([ + { version: "1.2.3", items: ["hidden", "Logs", "Newer fix", "Older fix"], totalItems: 4 }, + ]); + }); + + it("dates the offered release from the feed and older nightlies by their build day", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "0.0.41-nightly.20260914.1707", note: "- Nightly change 2" }, + { version: "0.0.41-nightly.20260913.1700", note: "- Nightly change 1" }, + ], + "0.0.41-nightly.20260914.1707", + "nightly", + "2026-09-14T18:30:00.000Z", + ); + + expect(result.releaseNotes.map(({ releasedAt }) => releasedAt)).toEqual([ + "2026-09-14T18:30:00.000Z", + "2026-09-13", + ]); + }); + it("drops releases from other trains before grouping on nightly", () => { const releaseNotes = [ { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 69d710ff7b269..2704563cfc0b8 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,6 +1,6 @@ import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -93,11 +93,46 @@ function isIgnoredReleaseNoteLine(line: string): boolean { interface ExtractedReleaseNoteItems { readonly items: ReadonlyArray; readonly totalItems: number; + readonly intro?: string; + readonly stats?: string; +} + +/** + * Reads a generated summary (`scripts/generate-release-notes.ts`): a `v1.2.3 · N changes…` + * header, a highlights paragraph, and fix bullets. PR numbers are left to the GitHub page. + */ +function extractReleaseNoteSummary(summary: string): ExtractedReleaseNoteItems { + let stats: string | undefined; + const intro: string[] = []; + const items: string[] = []; + for (const rawLine of stripMarkup(summary).split("\n")) { + const line = rawLine.trim().replace(/\s+/g, " "); + if (line === "" || /^#{1,6}\s+/.test(line)) continue; + if (stats === undefined && /^v\S+ · /.test(line)) { + stats = line.slice(line.indexOf(" · ") + 3); + continue; + } + const text = line + .replace(/^[-*]\s+/, "") + .replace(/\s*\((?:#\d+(?:,\s*)?)+\)\.?$/, "") + .trim(); + if (/^[-*]\s+/.test(line)) items.push(text); + else intro.push(text); + } + return { + items, + totalItems: items.length + intro.length, + ...(intro.length > 0 ? { intro: intro.join(" ") } : {}), + ...(stats ? { stats } : {}), + }; } function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { if (!note) return { items: [], totalItems: 0 }; + // Generated summaries fold GitHub's full list under this exact
block. + const summaryEnd = note.search(/
\s*\s*Full changelog\s*<\/summary>/i); + if (summaryEnd >= 0) return extractReleaseNoteSummary(note.slice(0, summaryEnd)); const items: string[] = []; let totalItems = 0; for (const rawLine of stripMarkup(note).split("\n")) { @@ -117,6 +152,15 @@ function extractReleaseNoteItems(note: string | null | undefined): ExtractedRele return { items: items.toReversed(), totalItems }; } +// The update feed dates only the offered version; prerelease versions carry their UTC build day. +const PRERELEASE_BUILD_DAY = /-(?:nightly|preview)\.(\d{4})(\d{2})(\d{2})\.\d+$/; + +function resolveReleasedAt(version: string, offeredVersion: string, releaseDate?: string) { + if (version === offeredVersion && releaseDate) return releaseDate; + const day = PRERELEASE_BUILD_DAY.exec(version); + return day ? `${day[1]}-${day[2]}-${day[3]}` : undefined; +} + interface NormalizedDesktopUpdateReleaseNotes { readonly releaseNotes: ReadonlyArray; readonly omittedReleaseCount: number; @@ -127,6 +171,7 @@ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, channel: DesktopUpdateChannel, + releaseDate?: string, ): NormalizedDesktopUpdateReleaseNotes { const rawNotes = ( typeof releaseNotes === "string" @@ -134,16 +179,20 @@ export function normalizeDesktopUpdateReleaseNotes( : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) : [] - ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel); + ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { - const { items, totalItems } = extractReleaseNoteItems(entry.note); + const { items, totalItems, intro, stats } = extractReleaseNoteItems(entry.note); if (totalItems === 0) return []; + const releasedAt = resolveReleasedAt(entry.version, fallbackVersion, releaseDate); return [ { version: entry.version, items, totalItems, + ...(intro ? { intro } : {}), + ...(stats ? { stats } : {}), + ...(releasedAt ? { releasedAt } : {}), }, ]; }); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index d79a374a668a5..b07735ceb7a29 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,13 +1,6 @@ -import type { DesktopUpdateChannel } from "@t3tools/contracts"; - -const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; // Preview builds share nightly branding but ship without an update feed. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { return PRERELEASE_VERSION_PATTERN.test(version); } - -export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { - return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest"; -} diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 1d8c21a42dfe5..d8d16b7dbfd62 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -35,6 +35,11 @@ export interface UpdatesHarnessOptions { readonly platform?: NodeJS.Platform; readonly packageType?: string | undefined; readonly installRecord?: string | undefined; + readonly appVersion?: string; + /** Starts from in-memory settings with this track saved, as after a restart. */ + readonly savedChannel?: DesktopAppSettings.DesktopSettings["updateChannel"]; + /** Adds a WSL backend beside the primary, as on a Windows host with WSL enabled. */ + readonly wslBackend?: boolean; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -42,6 +47,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let quitAndInstallCount = 0; let downloadCount = 0; let allowDowngrade = false; + const checkAllowedDowngrade: boolean[] = []; let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); @@ -86,6 +92,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; + checkAllowedDowngrade.push(allowDowngrade); }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.sync(() => { downloadCount += 1; @@ -149,7 +156,17 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { waitForReady: () => Effect.succeed(true), }; const layerBackend = DesktopBackendPool.layerTest( - options.backendInstances ?? [stubBackendInstance], + options.backendInstances ?? + (options.wslBackend + ? [ + stubBackendInstance, + { + ...stubBackendInstance, + id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), + label: Effect.succeed("Ubuntu"), + }, + ] + : [stubBackendInstance]), ); const layerEnvironment = DesktopEnvironment.layer({ @@ -157,7 +174,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, platform: options.platform ?? "darwin", processArch: "x64", - appVersion: "1.2.3", + appVersion: options.appVersion ?? "1.2.3", appPath: "/repo", isPackaged: true, resourcesPath: "/missing/resources", @@ -178,10 +195,13 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let testSettings: DesktopAppSettings.DesktopSettings = { ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + ...(options.savedChannel === undefined + ? {} + : { updateChannel: options.savedChannel, updateChannelConfiguredByUser: true }), }; const setUpdateChannelError = options.setUpdateChannelError; const layerSettings = - setUpdateChannelError || options.beforeSetUpdateChannel + setUpdateChannelError || options.beforeSetUpdateChannel || options.savedChannel ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => testSettings), load: Effect.sync(() => testSettings), @@ -279,6 +299,9 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { downloadCount: () => downloadCount, feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, fullChangelog: () => fullChangelog, + /** Whether each feed check could offer an older release, and the flag now. */ + checkAllowedDowngrade, + allowDowngrade: () => allowDowngrade, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 36dc282e89b47..e37dd8fb7a894 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -10,6 +10,7 @@ "preview": "astro preview" }, "dependencies": { + "@fontsource-variable/inter": "^5.3.0", "@t3tools/shared": "workspace:*", "astro": "^7.3.5", "sharp": "0.35.4" diff --git a/apps/marketing/public/fonts/dm-sans-OFL.txt b/apps/marketing/public/fonts/dm-sans-OFL.txt deleted file mode 100644 index a0eedfbf0df0e..0000000000000 --- a/apps/marketing/public/fonts/dm-sans-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/marketing/public/fonts/jetbrains-mono-OFL.txt b/apps/marketing/public/fonts/jetbrains-mono-OFL.txt deleted file mode 100644 index 821a3dac22aff..0000000000000 --- a/apps/marketing/public/fonts/jetbrains-mono-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. - -This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/marketing/src/assets/app-desktop.webp b/apps/marketing/src/assets/app-desktop.webp index 11b51331eef34..48c0c3e9abc50 100644 Binary files a/apps/marketing/src/assets/app-desktop.webp and b/apps/marketing/src/assets/app-desktop.webp differ diff --git a/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 b/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 deleted file mode 100644 index 6e84cba1949cd..0000000000000 Binary files a/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 b/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 deleted file mode 100644 index c75f7bf1b4656..0000000000000 Binary files a/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 deleted file mode 100644 index 289de93149abe..0000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 deleted file mode 100644 index de39acbc40690..0000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 deleted file mode 100644 index a53d52d6d02cb..0000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 deleted file mode 100644 index 82f96681c0762..0000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 deleted file mode 100644 index 4d09cda4a4c3f..0000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 deleted file mode 100644 index e38f5538f84e9..0000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 and /dev/null differ diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 6b4bf819e2d12..d1aea12f09bc9 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -2,8 +2,7 @@ import { getImage, Image } from "astro:assets"; import appIcon from "../assets/icon.webp"; import socialCard from "../assets/social-card.webp"; -import dmSansLatinUrl from "../assets/fonts/dm-sans-latin.woff2?url"; -import "../styles/fonts.css"; +import "@fontsource-variable/inter"; import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, @@ -58,13 +57,6 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); - @@ -153,22 +145,21 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); diff --git a/apps/marketing/src/styles/fonts.css b/apps/marketing/src/styles/fonts.css deleted file mode 100644 index 2f3933d61239c..0000000000000 --- a/apps/marketing/src/styles/fonts.css +++ /dev/null @@ -1,193 +0,0 @@ -/*! DM Sans v17 and JetBrains Mono v24. Licenses: /fonts/dm-sans-OFL.txt and /fonts/jetbrains-mono-OFL.txt. */ -/* Keep subset bytes out of the stylesheet so unused glyph ranges do not add transfer size. */ -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* cyrillic-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic-ext.woff2?no-inline) format("woff2"); - unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; -} -/* cyrillic */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic.woff2?no-inline) format("woff2"); - unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} -/* greek */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-greek.woff2?no-inline) format("woff2"); - unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; -} -/* vietnamese */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-vietnamese.woff2?no-inline) format("woff2"); - unicode-range: - U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, - U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; -} -/* latin-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* cyrillic-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic-ext.woff2?no-inline) format("woff2"); - unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; -} -/* cyrillic */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic.woff2?no-inline) format("woff2"); - unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} -/* greek */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-greek.woff2?no-inline) format("woff2"); - unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; -} -/* vietnamese */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-vietnamese.woff2?no-inline) format("woff2"); - unicode-range: - U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, - U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; -} -/* latin-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} 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/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx index 7931717682fa1..3d7605b71b5fb 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx @@ -21,6 +21,7 @@ export interface AndroidHeaderAction { readonly disabled?: boolean; readonly loading?: boolean; readonly selected?: boolean; + readonly subtitle?: string; readonly destructive?: boolean; readonly overflowOnly?: boolean; } @@ -178,10 +179,11 @@ export function AndroidScreenHeader(props: { actions={overflowActions.map((action, index) => ({ id: String(index), title: action.accessibilityLabel, + subtitle: action.subtitle, + state: action.selected ? "on" : undefined, attributes: { disabled: Boolean(action.disabled), destructive: action.destructive, - state: action.selected ? "on" : undefined, }, }))} onPressAction={({ nativeEvent }) => 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/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 65aaee6f0d479..99aed81cf2b82 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -411,6 +411,7 @@ function OpenDevicePreview({ access={access} platform={session.platform} deviceId={session.deviceId} + serveSimOnly={preview.serveSimOnly} colors={{ background: themeVariables["--color-sheet-solid"], foreground: themeVariables["--color-foreground"], diff --git a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx index 798e650b73e26..94db8527c5d67 100644 --- a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx +++ b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx @@ -45,6 +45,7 @@ export function DeviceStreamWebView({ access: props.access, platform: props.platform, deviceId: props.deviceId, + ...(props.serveSimOnly ? { serveSimOnly: true } : {}), colors: props.colors, }); return ( diff --git a/apps/mobile/src/features/devices/device-stream-document.ts b/apps/mobile/src/features/devices/device-stream-document.ts index 07a699567b6ea..abf045cdd172c 100644 --- a/apps/mobile/src/features/devices/device-stream-document.ts +++ b/apps/mobile/src/features/devices/device-stream-document.ts @@ -6,6 +6,8 @@ export interface DeviceStreamConfiguration { readonly access: DeviceHubAccess; readonly platform: DevicePlatform; readonly deviceId: string; + /** See `DeviceStreamTarget.serveSimOnly`. */ + readonly serveSimOnly?: boolean; readonly colors: { readonly background: string; readonly foreground: string; diff --git a/apps/mobile/src/features/devices/threadDevicePreviews.ts b/apps/mobile/src/features/devices/threadDevicePreviews.ts index aaf87ca9784e1..5ec92e113b8c2 100644 --- a/apps/mobile/src/features/devices/threadDevicePreviews.ts +++ b/apps/mobile/src/features/devices/threadDevicePreviews.ts @@ -14,6 +14,7 @@ export function threadDevicePreviews(state: DeviceServiceState | null, threadId: session, name: device?.name ?? (session.platform === "ios" ? "iOS Simulator" : "Android Emulator"), description: [device?.version, host?.label].filter(Boolean).join(" · "), + serveSimOnly: host?.kind === "simlock", }; }); } 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" ? ( - - + ), + dismissLabel: "Dismiss fallback model notice", + onDismiss, + }; +} + function UsageLimitsBannerBody({ report, environmentId, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 4a8a894bedbb6..d2e259692204b 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -25,7 +25,11 @@ import { projectGroupsSpanEnvironments, } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useEnvironments, + usePrimaryEnvironmentId, + useEnvironmentProviders, +} from "~/state/environments"; import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; @@ -45,6 +49,8 @@ import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { TriageDraftHero } from "./TriageDraftHero"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveProjectDefaultProviderModelSelection } from "../../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; const ADD_PROJECT_VALUE = "add-project"; @@ -67,6 +73,7 @@ export function DraftHeroHeadline({ const projects = useProjects(); const threads = useThreadShells(); const { environments } = useEnvironments(); + const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); @@ -193,13 +200,33 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - const environmentSettings = environments.find( + const environmentConfig = environments.find( (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings + )?.serverConfig; + const environmentSettings = environmentConfig?.settings; + const defaultSelection = environmentSettings ? resolveProjectSettings(environmentSettings, project.id, project).settings .defaultModelSelection : project.defaultModelSelection; + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + defaultSelection ?? + (useComposerDraftStore.getState().stickyActiveProvider !== null || !environmentSettings + ? null + : resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(project.environmentId) ?? [], + environmentSettings, + project.id, + )), + fallbackSelection: environmentSettings?.fallbackModelSelection ?? null, + providers: providersByEnvironment.get(project.environmentId) ?? [], + ...(environmentSettings ? { settings: environmentSettings } : {}), + projectId: project.id, + now: Date.now(), + }); + const defaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : defaultSelection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3ee079809f14e..8f4b15cc11f91 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3841,9 +3841,15 @@ it("keeps the working header in place across worktree setup handoff", () => { }); describe("failed turn transcript", () => { - it.each(["provider_error", "usage_limit"] as const)( - "keeps historical %s failures and preceding work visible without disclosures", - (failureClass) => { + it.each([ + { position: "historical", failureClass: "provider_error" }, + { position: "historical", failureClass: "usage_limit" }, + { position: "latest", failureClass: "usage_limit" }, + ] as const)( + "keeps $position $failureClass failures visible with preceding work grouped appropriately", + ({ position, failureClass }) => { + const isLatest = position === "latest"; + const commands = isLatest ? ["pwd", "ls", "false"] : ["pwd"]; const runId = RunId.make("failed-run"); const threadId = ThreadId.make("failed-thread"); const at = DateTime.makeUnsafe("2026-09-20T12:00:00Z"); @@ -3880,23 +3886,24 @@ describe("failed turn transcript", () => { attachments: [], }, }, - { - position: 1, + ...commands.map((input, index): OrchestrationV2ProjectedTurnItem => ({ + position: index + 1, visibility: "local", sourceThreadId: threadId, - sourceItemId: TurnItemId.make("command"), + sourceItemId: TurnItemId.make(index === 0 ? "command" : `command-${index}`), item: { ...base, - id: TurnItemId.make("command"), + id: TurnItemId.make(index === 0 ? "command" : `command-${index}`), + ordinal: index + 1, type: "command_execution", status: "completed", - input: "pwd", + input, output: "", exitCode: 0, }, - }, + })), { - position: 2, + position: commands.length + 1, visibility: "local", sourceThreadId: threadId, sourceItemId: TurnItemId.make("failure"), @@ -3918,20 +3925,24 @@ describe("failed turn transcript", () => { timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items, optimisticMessages: [], - }).map((entry) => ({ - ...entry, - attempt: { - id: RunAttemptId.make("superseded-attempt"), - runId, - attemptOrdinal: 1, - rootNodeId: NodeId.make("superseded-root"), - status: "superseded" as const, - completedAt: null, - }, - })), + }).map((entry) => + isLatest + ? entry + : { + ...entry, + attempt: { + id: RunAttemptId.make("superseded-attempt"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("superseded-root"), + status: "superseded" as const, + completedAt: null, + }, + }, + ), latestRun: { - runId: RunId.make("newer-run"), - status: "completed", + runId: isLatest ? runId : RunId.make("newer-run"), + status: isLatest ? "failed" : "completed", startedAt: DateTime.formatIso(at), completedAt: DateTime.formatIso(at), }, @@ -3939,16 +3950,19 @@ describe("failed turn transcript", () => { turnDiffSummaries: [], supportsConversationRollback: false, }); - expect( - rows.some( - (row) => - row.kind === "turn-fold" || row.kind === "attempt-fold" || row.kind === "work-toggle", - ), - ).toBe(false); + expect(rows.some((row) => row.kind === "turn-fold" || row.kind === "attempt-fold")).toBe( + false, + ); const work = rows.flatMap((row) => (row.kind === "work" ? row.groupedEntries : [])); - expect(work.map((entry) => entry.id)).toEqual(["command", "failure"]); + expect(rows.find((row) => row.kind === "work-toggle")).toEqual( + isLatest ? expect.objectContaining({ hiddenCount: 3, expanded: false }) : undefined, + ); + expect(work.map((entry) => entry.id)).toEqual( + isLatest ? ["failure"] : ["command", "failure"], + ); expect(work.at(-1)?.detail).toBe("The provider stopped this turn.\nRetry later."); expect(work.at(-1)?.createdAt).toBe("2026-09-20T12:00:00.000Z"); + if (isLatest) return; const entriesWithoutTools = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items.filter((row) => row.item.type !== "command_execution"), optimisticMessages: [], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4ea9e852edfba..c26bcf51a718a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -973,6 +973,24 @@ function failedTimelineRunIds( return failed; } +/** Usage-limit pauses keep their tool groups; any other failure itemizes the run's work. */ +function itemizedFailedRunIds( + entries: ReadonlyArray, + failedRunIds: ReadonlySet, +) { + const usageLimited = new Set(); + const otherFailures = new Set(); + for (const entry of entries) { + const item = timelineEntryFailedItem(entry); + if (item?.runId) { + (item.failure.class === "usage_limit" ? usageLimited : otherFailures).add(item.runId); + } + } + return new Set( + [...failedRunIds].filter((runId) => !usageLimited.has(runId) || otherFailures.has(runId)), + ); +} + function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; @@ -1410,6 +1428,7 @@ export function deriveMessagesTimelineRows(input: { const nextRows: MessagesTimelineRow[] = []; const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); + const itemizedRunIds = itemizedFailedRunIds(timelineEntries, failedRunIds); const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( @@ -1436,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: { liveSubagentEntryIds, completeSupersededReplyEntryIds: completeSupersededReplyIds, }); + const timelineEntryById = new Map(timelineEntries.map((entry) => [entry.id, entry])); const collapsedEntryIds = new Set(); const collapsedFoldMemberIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -1681,14 +1701,14 @@ export function deriveMessagesTimelineRows(input: { const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); if (turnFold) { - const deferredWorkItemIds = timelineEntries.flatMap((entry) => - turnFold.entryIds.has(entry.id) && - entry.kind === "work" && - entry.entry.projectedItem?.item.deferredWork && - !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) + const deferredWorkItemIds = [...turnFold.entryIds].flatMap((entryId) => { + const entry = timelineEntryById.get(entryId); + return entry?.kind === "work" && + entry.entry.projectedItem?.item.deferredWork && + !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) ? [String(entry.entry.projectedItem.sourceItemId)] - : [], - ); + : []; + }); nextRows.push({ kind: "turn-fold", id: `turn-fold:${turnFold.runId}`, @@ -1771,7 +1791,7 @@ export function deriveMessagesTimelineRows(input: { if (timelineEntry.kind === "work") { if ( isPendingApproval(timelineEntry.entry) || - (timelineEntry.entry.runId != null && failedRunIds.has(timelineEntry.entry.runId)) || + (timelineEntry.entry.runId != null && itemizedRunIds.has(timelineEntry.entry.runId)) || (timelineEntry.entry.itemType === "error" && timelineEntry.entry.toolLifecycleStatus === "failed") || timelineEntry.entry.tone === "error" || @@ -1797,6 +1817,8 @@ export function deriveMessagesTimelineRows(input: { !nextEntry || nextEntry.kind !== "work" || nextEntry.entry.sourceActivityKind === "context-compaction" || + (nextEntry.entry.itemType === "error" && + nextEntry.entry.toolLifecycleStatus === "failed") || nextEntry.entry.tone === "error" || nextEntry.entry.sourceActivityKind === "runtime.error" || nextEntry.entry.itemType === "system_notice" || diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index b7430716f96f9..c5a8cdf64c18f 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -41,6 +41,7 @@ import { } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { registerModelPickerContent } from "../../modelPickerVisibility"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { modelPickerJumpCommandForIndex, @@ -377,6 +378,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const [searchQuery, setSearchQuery] = useState(""); const modelListRef = useRef(null); const pickerContentRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes this picker's mount to global keydown handlers + useLayoutEffect(() => registerModelPickerContent(), []); const highlightedModelKeyRef = useRef(null); const { favorites } = useEnvironmentModelPreferences(props.environmentId); const activeEntry = props.instanceEntries.find( diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index b34867b2668f3..720ce4d6c443d 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -189,6 +189,19 @@ describe("host editor access", () => { }); }); +describe("remote editor links", () => { + it("opens the link from the fallback when the picker is not mounted", async () => { + await using fixture = await setup(); + const { screen, pressOpenFavorite, FavoriteEditorFallback } = fixture; + state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }; + await act(async () => screen.rerender()); + + expect((await pressOpenFavorite()).defaultPrevented).toBe(true); + expect(state.openUrl).toHaveBeenCalledWith(expect.stringContaining("ssh-remote+sol")); + expect(state.run).not.toHaveBeenCalled(); + }); +}); + describe("open in editor announcement", () => { it("speaks only after the shortcut opened the editor, from the picker or the fallback", async () => { await using fixture = await setup(); diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 8c8249c67b1a0..833c3f3784987 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -1,11 +1,16 @@ -import type { EditorId, EnvironmentId, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + buildRemoteOpenUrl, + type EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { useEffect } from "react"; import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { isOpenFavoriteEditorShortcut } from "../../keybindings"; import { announce } from "../../lib/announce"; -import { useRemoteOpenState } from "../../remoteOpen"; +import { openRemoteEditorUrl, useRemoteCapableEditors, useRemoteOpenState } from "../../remoteOpen"; import { registerCommands } from "../../shortcuts/dispatcher"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -49,18 +54,39 @@ export function useOpenFavoriteEditorShortcut({ openInCwd: string | null; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); - const [preferredEditor] = usePreferredEditor(availableEditors); const remote = useRemoteOpenState(environmentId); + const remoteCapableEditors = useRemoteCapableEditors(); + // Matches the picker: remote editors run on the viewing machine. + const [preferredEditor] = usePreferredEditor( + remote.mode === "local-exec" ? availableEditors : remoteCapableEditors, + ); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled locally + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled useEffect(() => { - if (!enabled || remote.mode !== "local-exec") return; + if (!enabled || remote.mode === "remote-unavailable") return; const handler = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || !isOpenFavoriteEditorShortcut(event, keybindings)) return; + if ( + event.repeat || + event.defaultPrevented || + !isOpenFavoriteEditorShortcut(event, keybindings) + ) + return; if (!openInCwd || !preferredEditor) return; event.preventDefault(); const label = editorLabelForPlatform(preferredEditor, navigator.platform); + if (remote.mode === "remote-links") { + const url = buildRemoteOpenUrl({ + editor: preferredEditor, + ...remote.host, + absolutePath: openInCwd, + }); + if (url === undefined) return; + void openRemoteEditorUrl(url).then((opened) => { + if (opened) announce(`Opened in ${label}`); + }); + return; + } void openInEditorMutation({ environmentId, input: { @@ -80,6 +106,6 @@ export function useOpenFavoriteEditorShortcut({ openInCwd, openInEditorMutation, preferredEditor, - remote.mode, + remote, ]); } diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 20ecde3272276..ca2497bd20ee8 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -153,12 +153,6 @@ export function ThreadAutomationsPanel(props: { } > - {tasksQuery.error !== null ? ( -

- Could not load automations: {tasksQuery.error} -

- ) : null} -
    {boundTasks.map((task) => (
  • ))}
+ {/* Kept last: the details card treats a section whose last child is empty as empty. */} + {tasksQuery.error !== null ? ( +

+ Could not load automations: {tasksQuery.error} +

+ ) : null} ); } diff --git a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx index 37110875ed0ea..a8728d7879dd4 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx @@ -42,10 +42,13 @@ function Card({ threadRef, inline, fixture, + rows = 1, }: { threadRef: ScopedThreadRef; inline: boolean; fixture: ReturnType; + /** Rows of 100px shown at full density. Compact always takes 50px. */ + rows?: number; }) { return ( // The web root renders in StrictMode, which re-runs ref callbacks on every mount. @@ -63,8 +66,17 @@ function Card({ detailsCardPlacement: inline ? placement : null, }} > - - {() =>

Details for {threadRef.threadId}

} + + {(density) => ( +

+ Details for {threadRef.threadId} +

+ )}
@@ -136,3 +148,13 @@ it("reopens after a normal close", async () => { await setOpen(true); await expect.element(page.getByText("Details for thread-b")).toBeVisible(); }); + +it("returns to full density when hidden items make the content fit", async () => { + using fixture = setup(); + await using screen = await render(); + const card = page.getByRole("region", { name: "Thread details" }); + await expect.element(card).toHaveAttribute("data-density", "compact"); + + await screen.rerender(); + await expect.element(card).toHaveAttribute("data-density", "full"); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 4add56b80eb08..a5df136f14615 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -1,5 +1,11 @@ import { workspaceAreaProps } from "../../lib/workspaceFocus"; -import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; +import { + useLayoutEffect, + useState, + type MouseEventHandler, + type ReactNode, + type RefObject, +} from "react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { ScrollArea } from "../ui/scroll-area"; import { cn } from "../../lib/utils"; @@ -17,11 +23,16 @@ export function ThreadDetailsCard({ threadRef, anchor, handle, + contentKey, + onContextMenu, children, }: { threadRef: ScopedThreadRef; anchor: RefObject; handle: ReturnType; + /** Changes when the set of rendered items changes, so cached heights are measured again. */ + contentKey: string; + onContextMenu?: MouseEventHandler; children: (density: "full" | "compact" | "essential") => ReactNode; }) { const detailsArea = workspaceAreaProps("details", 3, "Thread details"); @@ -36,7 +47,7 @@ export function ThreadDetailsCard({ const popoverOpen = useRightPanelStore((state) => selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "popover"), ); - const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}`; + const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}:${contentKey}`; const [measurements, setMeasurements] = useState({ key: measurementKey, heights: { full: 0, compact: 0 }, @@ -109,6 +120,7 @@ export function ThreadDetailsCard({ "max-h-[min(calc(100dvh-6.5rem),calc(var(--available-height,100dvh)-1rem))]", )} style={placement ? { maxHeight: height } : undefined} + onContextMenu={onContextMenu} data-thread-details-card > diff --git a/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx new file mode 100644 index 0000000000000..d84c13809eaf6 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx @@ -0,0 +1,45 @@ +import { + getClientSettings, + updateClientSettings, + useClientSettings, +} from "../../hooks/useSettings"; +import { MenuCheckboxItem, MenuGroup, MenuGroupLabel, MenuItem, MenuSeparator } from "../ui/menu"; +import { + isDefaultThreadDetailsVisibility, + resolveThreadDetailsVisibility, + RESET_THREAD_DETAILS_ITEMS, + setThreadDetailsItem, + THREAD_DETAILS_ITEMS, +} from "./threadDetailsItems"; + +/** Menu body that shows and hides thread details items. The card's context menu and Settings share it. */ +export function ThreadDetailsItemsMenuItems() { + const visibility = useClientSettings(resolveThreadDetailsVisibility); + return ( + <> + + Show in thread details + {THREAD_DETAILS_ITEMS.map((item) => ( + + updateClientSettings({ + threadDetailsItems: setThreadDetailsItem(getClientSettings(), item, checked), + }) + } + > + {item.label} + + ))} + + + updateClientSettings(RESET_THREAD_DETAILS_ITEMS)} + > + Reset to default + + + ); +} diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 1463c97f9ff6f..35a954db9d43e 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -16,13 +16,25 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { memo, type ComponentProps } from "react"; +import { memo, useState, type ComponentProps } from "react"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; import { ThreadServersPanel } from "./ThreadServersPanel"; +import { ThreadDetailsUsage, type ThreadDetailsUsageInput } from "./ThreadDetailsUsage"; +import { ThreadDetailsItemsMenuItems } from "./ThreadDetailsItemsMenu"; +import { resolveThreadDetailsVisibility, THREAD_DETAILS_ITEMS } from "./threadDetailsItems"; +import { useClientSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { SlidersHorizontalIcon } from "lucide-react"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, +} from "./threadDetailsPanelStyles"; +import { Menu, MenuPopup } from "../ui/menu"; const GitActionsControl = memo(BaseGitActionsControl); @@ -67,12 +79,15 @@ interface ThreadDetailsPanelProps extends Pick< input: NewProjectScriptInput, ) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; + usage: ThreadDetailsUsageInput; } export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { + const visible = useClientSettings(resolveThreadDetailsVisibility); + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); const fileScripts = useT3ProjectFileScripts( props.environmentId, - props.activeProjectScripts ? props.gitCwd : null, + props.activeProjectScripts && visible.scripts ? props.gitCwd : null, ); const activeThreadRef = { environmentId: props.environmentId, threadId: props.threadId }; const branchToolbarProps = { @@ -106,97 +121,181 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadRef={{ environmentId: props.environmentId, threadId: props.threadId }} anchor={props.anchor} handle={props.handle} + contentKey={THREAD_DETAILS_ITEMS.filter((item) => visible[item.id]) + .map((item) => item.id) + .join(",")} + onContextMenu={(event) => { + // Popups the card opens bubble here through React; they, fields, links, and selected text + // keep their own menus. + const target = event.target; + const selection = window.getSelection(); + if ( + !(target instanceof Element) || + !event.currentTarget.contains(target) || + target.closest("input, textarea, [contenteditable], a[href]") || + (selection && + !selection.isCollapsed && + (event.currentTarget.contains(selection.anchorNode) || + event.currentTarget.contains(selection.focusNode))) + ) + return; + event.preventDefault(); + setMenuPosition({ x: event.clientX, y: event.clientY }); + }} > - {(density) => ( - <> - -
- {density === "full" ? ( - + {(density) => { + // Section flags follow the rows that will render, so no empty section steals the divider rule. + const show = { + environment: density === "full" && visible.environment, + openIn: density !== "essential" && visible.openIn && props.showOpenInPicker, + scripts: visible.scripts && props.activeProjectScripts !== undefined, + branch: props.gitCwd !== null && props.isGitRepo && visible.branch, + git: + props.gitCwd !== null && + props.activeProjectName !== undefined && + (visible.gitActions || (visible.changes && props.isGitRepo)), + }; + return ( + <> +
+ {show.environment || show.openIn || show.scripts ? ( + +
+ {show.environment ? ( + + ) : null} + + {show.openIn ? ( + + ) : null} + + {show.scripts && props.activeProjectScripts ? ( + + ) : null} +
+
+ ) : null} + + {props.gitCwd && (show.branch || show.git) ? ( + +
+ {show.branch ? ( + + ) : null} + {show.git ? ( + + ) : null} +
+
+ ) : null} + + {density === "full" && !props.draftId && visible.servers ? ( + ) : null} - {density !== "essential" && props.showOpenInPicker ? ( - ) : null} - {props.activeProjectScripts ? ( - ) : null} -
- - - {props.gitCwd ? ( - -
- {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - - ) : null} -
-
- ) : null} - {density === "full" && !props.draftId ? ( - - ) : null} + {visible.usage ? : null} - {density === "full" && !props.draftId ? ( - - ) : null} + {/* Shown only when no section above has content, so an emptied card can be refilled. */} +
+ { + const rect = event.currentTarget.getBoundingClientRect(); + setMenuPosition({ x: rect.left, y: rect.bottom }); + }} + > + + + Choose what to show + + +
+
- {density === "full" && !props.draftId ? ( - - ) : null} - - )} + !open && setMenuPosition(null)} + > + + new DOMRect(menuPosition.x, menuPosition.y, 0, 0), + } + : undefined + } + align="start" + side="bottom" + > + + + + + ); + }}
); } diff --git a/apps/web/src/components/chat/ThreadDetailsSection.tsx b/apps/web/src/components/chat/ThreadDetailsSection.tsx index 00aacd5a7b152..de5ce6c63825d 100644 --- a/apps/web/src/components/chat/ThreadDetailsSection.tsx +++ b/apps/web/src/components/chat/ThreadDetailsSection.tsx @@ -22,7 +22,12 @@ export function ThreadDetailsSection({ {...props} aria-labelledby={showHeading ? headingId : undefined} aria-label={showHeading ? undefined : title} - className={cn("px-2 pt-2 pb-2.5", separated && "border-t border-border/65")} + // A row can still render nothing (a fresh draft quiets Git actions), so an empty section + // collapses, and a divider only follows a section that has content. + className={cn( + "px-2 pt-2 pb-2.5 has-[>div:last-child:empty]:hidden", + separated && "border-border/65 [section:not(:has(>div:last-child:empty))~&]:border-t", + )} >
= { + ahead: "Ahead of pace", + on: "On pace", + under: "Under pace", +}; + +export interface ThreadDetailsUsageInput { + readonly providerInstanceId: ProviderInstanceId | null; + readonly providers: readonly ServerProvider[]; + readonly sources: UsageLimitSourceSnapshots; + readonly contextWindow: ContextWindowSnapshot | null; +} + +/** + * The selected provider's limit windows and the thread's context window. + * Every row reads as room left, so a longer bar always means more headroom. + */ +export function ThreadDetailsUsage({ + providerInstanceId, + providers, + sources, + contextWindow, +}: ThreadDetailsUsageInput) { + const nowMinute = useNowMinute(); + const now = Date.parse(`${nowMinute}:00Z`); + const report = + providerInstanceId !== null + ? collectProviderUsageLimits(providerInstanceId, providers, sources, now) + : null; + // Only this instance's data: the report keeps one instance per login, and any other entry may be + // a different login or workspace, so fall back to the instance's own snapshot. + const limits = + report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? + providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits; + const notice = limits ? limitsNotice(limits) : null; + if (!limits && !contextWindow) return null; + + return ( + +
+ {notice ? ( + {notice}} + /> + ) : null} + {limits && !notice + ? limits.windows.map((window) => ) + : null} + {contextWindow ? : null} +
+
+ ); +} + +function LimitRow({ + window, + now, +}: { + readonly window: ServerProviderUsageWindow; + readonly now: number; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const left = remainingPercent(window); + const pace = paceOf(window, now); + const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN; + return ( + + + {window.label}: {left}% left + + {window.resetsAt && resetsAt > now ? ( + + Resets in {formatDuration(resetsAt - now)} ·{" "} + {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} + + ) : null} + {pace ? {PACE_LABEL[pace]} : null} + + } + /> + ); +} + +function ContextRow({ + usage, + spaced, +}: { + readonly usage: ContextWindowSnapshot; + readonly spaced: boolean; +}) { + const maxTokens = usage.maxTokens ?? null; + const left = + maxTokens !== null && usage.usedPercentage !== null + ? Math.round(100 - Math.max(0, Math.min(100, usage.usedPercentage))) + : null; + const used = formatContextWindowTokens(usage.usedTokens); + return ( + + {left !== null ? Context: {left}% left : null} + + {maxTokens !== null + ? `${used} of ${formatContextWindowTokens(maxTokens)} tokens used` + : `${used} tokens used`} + + + } + value={left === null ? used : undefined} + /> + ); +} + +/** One label, hairline, and value row; the whole row opens the details tooltip. */ +function UsageRow({ + label, + left, + tooltip, + value, + muted = false, + className, +}: { + readonly label: string; + readonly left: number | null; + readonly tooltip: ReactNode; + /** Replaces the percentage when there is none. */ + readonly value?: string | undefined; + readonly muted?: boolean; + readonly className?: string | undefined; +}) { + const tone = left === null ? null : left <= 10 ? "error" : left <= 20 ? "warning" : null; + return ( + + + } + > + {label} + {left !== null ? ( + + {left > 0 ? ( + + ) : null} + + ) : null} + + {value ?? `${left}%`} + + + {/* Rows sit 8px inside the card; clear its edge instead of overlapping it. */} + +
{tooltip}
+
+
+ ); +} diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f02780bed3af1..79a29d293dc49 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -616,9 +616,12 @@ export const TraitsPicker = memo(function TraitsPicker({ ) : null; // Workflow levels such as Ultracode always show their name; the setting names every level. + // Traits the bars cannot show, such as an agent mode, appear as words in the composer. const effortWord = effortGlyph ? (effortGlyph.special ?? (effortDisplay === "label" ? effortGlyph.label : null)) - : null; + : isComposerOwned + ? triggerLabel + : null; return ( ) : null} + ) : effortWord !== null ? ( + <> + {effortWord} +
)} diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx new file mode 100644 index 0000000000000..521d9c47f316e --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx @@ -0,0 +1,212 @@ +import "~/index.css"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { Cause } from "effect"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +const mocks = vi.hoisted(() => ({ + update: + vi.fn<(input: unknown) => Promise<{ _tag: "Success" } | { _tag: "Failure"; cause: unknown }>>(), + notify: vi.fn(), +})); +vi.mock( + import("~/state/pullRequests"), + () => ({ pullRequestEnvironment: { update: {} } }) as never, +); +vi.mock( + import("~/state/use-atom-command"), + () => ({ useAtomCommand: () => mocks.update }) as never, +); + +vi.mock(import("../ui/toast"), () => ({ toastManager: { add: mocks.notify } }) as never); +vi.mock(import("~/state/server"), async () => { + const { Atom } = await import("effect/reactivity"); + return { primaryServerKeybindingsAtom: Atom.make([]) } as never; +}); +vi.mock(import("./PullRequestThreadLinks"), () => ({ PullRequestThreadLinks: () => null })); +vi.mock( + import("./PullRequestDetailActions"), + () => ({ ActOnEnvironmentPicker: () => null }) as never, +); + +import { render } from "../../../test/render"; +import { PullRequestActionsMenu } from "./PullRequestActionsMenu"; + +const reference = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 7 }; +const environmentId = EnvironmentId.make("environment"); + +type Detail = Parameters[0]["detail"]; + +const openDetail: Detail = { + url: "https://github.com/owner/repo/pull/7", + state: "open", + isDraft: false, + headBranch: "feature", + maintainerCanModify: true, + observedAt: 1, + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: true, + review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true, maintainerEdits: true }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + maintainerEdits: true, + }, +}; + +function setup() { + mocks.update.mockReset().mockResolvedValue({ _tag: "Success" }); + mocks.notify.mockReset(); + return (detail: Detail = openDetail) => ( + {}} + refreshing={false} + refreshFromHost={async () => {}} + handoff={null} + attachesToComposer={false} + askAboutPullRequest={() => {}} + explainPullRequest={() => {}} + canFixFindings={false} + startFixFindings={() => {}} + handoffLabels={{ fixFindings: "Fix review findings" }} + canWriteGuide={false} + guideRunning={false} + guideWritten={false} + writeGuide={() => {}} + pickableEnvironments={[]} + actingEnvironmentId={environmentId} + onActingEnvironmentChange={() => {}} + can={() => false} + autoMergeArmed={false} + showsDraftToggle={false} + showsMergeNow={false} + showsAutoMerge={false} + showsMergeMethods={false} + allowedMergeMethods={[]} + selectedMergeMethod="squash" + onSelectMergeMethod={() => {}} + showsDeleteBranch={false} + environmentConnected={true} + canWriteSourceControl={true} + onMaintainerEditsSaved={() => {}} + confirmDeleteBranch={async () => {}} + pendingAction={null} + perform={async () => true} + openOnHostText="Open on GitHub" + copyReference={() => {}} + /> + ); +} + +it("saves maintainer edits, locks while saving, and restores the saved value when a save fails", async () => { + const control = setup(); + await using screen = await render(control()); + await screen.getByRole("button", { name: "More pull request actions" }).click(); + const checkbox = page.getByRole("menuitemcheckbox", { name: "Allow edits from maintainers" }); + await expect.element(checkbox).toBeChecked(); + await checkbox.click(); + await expect.element(page.getByRole("status")).not.toBeInTheDocument(); + expect(mocks.update).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { ...reference, maintainerCanModify: false }, + }); + + let settle!: (value: { _tag: "Failure"; cause: unknown }) => void; + mocks.update.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }), + ); + await checkbox.click(); + await expect.element(checkbox).toBeChecked(); + await expect.element(checkbox).toBeDisabled(); + await expect.element(page.getByRole("status")).toHaveTextContent("Saving…"); + settle({ + _tag: "Failure", + cause: Cause.fail(new Error("Resource not accessible by integration")), + }); + await expect.element(checkbox).not.toBeChecked(); + expect(mocks.notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + type: "error", + title: "Could not save maintainer access", + description: "Resource not accessible by integration", + }), + ); +}); + +it("keeps a saved choice through a stale refresh, then follows the host", async () => { + const control = setup(); + await using screen = await render(control()); + await screen.getByRole("button", { name: "More pull request actions" }).click(); + const checkbox = page.getByRole("menuitemcheckbox", { name: "Allow edits from maintainers" }); + let settle!: (value: { _tag: "Success" }) => void; + mocks.update.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }), + ); + await checkbox.click(); + await screen.rerender(control({ ...openDetail, observedAt: 2 })); + settle({ _tag: "Success" }); + await expect.element(page.getByRole("status")).not.toBeInTheDocument(); + await screen.rerender(control({ ...openDetail, observedAt: 2 })); + await expect.element(checkbox).not.toBeChecked(); + await screen.rerender(control({ ...openDetail, maintainerCanModify: false, observedAt: 3 })); + await expect.element(checkbox).not.toBeChecked(); + await screen.rerender(control({ ...openDetail, maintainerCanModify: true, observedAt: 4 })); + await expect.element(checkbox).toBeChecked(); +}); + +it("follows the host's first reading after a save, even when it matches the old value", async () => { + const control = setup(); + await using screen = await render(control()); + await screen.getByRole("button", { name: "More pull request actions" }).click(); + const checkbox = page.getByRole("menuitemcheckbox", { name: "Allow edits from maintainers" }); + await checkbox.click(); + await expect.element(page.getByRole("status")).not.toBeInTheDocument(); + await expect.element(checkbox).not.toBeChecked(); + await screen.rerender(control({ ...openDetail, observedAt: 2 })); + await expect.element(checkbox).toBeChecked(); +}); + +it.each<[string, Detail]>([ + ["closed", { ...openDetail, state: "closed" }], + [ + "not authored by the viewer", + { + ...openDetail, + viewerPermissions: { ...openDetail.viewerPermissions, maintainerEdits: false }, + }, + ], + [ + "on a host without maintainer edits", + { + ...openDetail, + capabilities: { ...openDetail.capabilities, edit: { changeRequest: true, comment: true } }, + }, + ], +])("hides maintainer edits on a pull request that is %s", async (_, detail) => { + const control = setup(); + await using screen = await render(control(detail)); + await screen.getByRole("button", { name: "More pull request actions" }).click(); + await expect.element(page.getByRole("menuitem", { name: "Open on GitHub" })).toBeVisible(); + expect(document.querySelector('[role="menuitemcheckbox"]')).toBeNull(); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx index c920bbd3e19ff..df402185ce350 100644 --- a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx @@ -1,5 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; +import { + canEditPullRequestMaintainerEdits, + maintainerEditsChoiceInForce, + type MaintainerEditsChoice, +} from "@t3tools/client-runtime/pull-request-editing"; import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestAction, @@ -20,13 +26,17 @@ import { SparklesIcon, Trash2Icon, } from "lucide-react"; +import { useId, useState } from "react"; import { shortcutLabelForCommand } from "~/keybindings"; import { readLocalApi } from "~/localApi"; +import { pullRequestEnvironment } from "~/state/pullRequests"; import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; import { Button } from "../ui/button"; import { Menu, + MenuCheckboxItem, MenuItem, MenuPopup, MenuRadioGroup, @@ -36,11 +46,13 @@ import { MenuTrigger, } from "../ui/menu"; import { RefreshIcon } from "../ui/refresh-icon"; +import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ActOnEnvironmentPicker, type PerformPullRequestAction } from "./PullRequestDetailActions"; import { PULL_REQUEST_MERGE_METHOD_LABELS, pullRequestActionMenuHasGroup, + readableFailure, } from "./pullRequestDetail.logic"; import { PullRequestGlyph } from "./pullRequestIcons"; import type { PickableEnvironment } from "./pullRequestProjectAssignment.logic"; @@ -83,6 +95,8 @@ export function PullRequestActionsMenu({ onSelectMergeMethod, showsDeleteBranch, environmentConnected, + canWriteSourceControl, + onMaintainerEditsSaved, confirmDeleteBranch, pendingAction, perform, @@ -92,7 +106,17 @@ export function PullRequestActionsMenu({ environmentId: EnvironmentId; reference: PullRequestRef; pullRequestKey: string; - detail: Pick; + detail: Pick< + PullRequestDetail, + | "url" + | "state" + | "isDraft" + | "headBranch" + | "capabilities" + | "viewerPermissions" + | "maintainerCanModify" + | "observedAt" + >; terminology: ChangeRequestTerminology; linkThreadRef: ScopedThreadRef | null; onThreadPickerOpenChange: (open: boolean) => void; @@ -123,6 +147,8 @@ export function PullRequestActionsMenu({ onSelectMergeMethod: (method: PullRequestMergeMethod) => void; showsDeleteBranch: boolean; environmentConnected: boolean; + canWriteSourceControl: boolean; + onMaintainerEditsSaved: () => void; confirmDeleteBranch: () => Promise; pendingAction: PullRequestAction | null; perform: PerformPullRequestAction; @@ -132,6 +158,47 @@ export function PullRequestActionsMenu({ const keybindings = useAtomValue(primaryServerKeybindingsAtom); const inlineConfirm = useInlineActionConfirm(pullRequestKey); const actionPending = pendingAction !== null; + const maintainerEditsHintId = useId(); + const updatePullRequest = useAtomCommand(pullRequestEnvironment.update, { + reportFailure: false, + }); + const [maintainerEditsChoice, setMaintainerEditsChoice] = useState( + null, + ); + const [maintainerEditsSaving, setMaintainerEditsSaving] = useState(false); + const maintainerEditsChoiceKept = maintainerEditsChoiceInForce( + maintainerEditsChoice, + pullRequestKey, + detail.observedAt, + maintainerEditsSaving, + ); + if (maintainerEditsChoiceKept !== maintainerEditsChoice) { + setMaintainerEditsChoice(maintainerEditsChoiceKept); + } + const maintainerCanModify = maintainerEditsChoiceKept?.value ?? detail.maintainerCanModify; + const canSaveMaintainerEdits = + canWriteSourceControl && environmentConnected && !maintainerEditsSaving; + const saveMaintainerEdits = async (value: boolean) => { + if (!canSaveMaintainerEdits) return; + const previousChoice = maintainerEditsChoice; + setMaintainerEditsChoice({ key: pullRequestKey, observedAt: detail.observedAt, value }); + setMaintainerEditsSaving(true); + const result = await updatePullRequest({ + environmentId, + input: { ...reference, maintainerCanModify: value }, + }); + setMaintainerEditsSaving(false); + if (result._tag === "Failure") { + setMaintainerEditsChoice(previousChoice); + toastManager.add({ + type: "error", + title: "Could not save maintainer access", + description: readableFailure(squashAtomCommandFailure(result), "Try again."), + }); + return; + } + onMaintainerEditsSaved(); + }; return (
@@ -218,6 +285,33 @@ export function PullRequestActionsMenu({ /> ) : null} + {canEditPullRequestMaintainerEdits(detail) ? ( + <> + void saveMaintainerEdits(value)} + > + + Allow edits from maintainers + + Maintainers can push to this branch. If your fork has workflows, this can also + give access to its secrets. + + {maintainerEditsSaving ? ( + + Saving… + + ) : null} + + + + + ) : null} {detail.state === "open" ? ( <> {showsDraftToggle ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d1b165c62147b..94f4ccda82558 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -19,6 +19,7 @@ import { ChevronDownIcon, ChevronRightIcon, Columns2Icon, + FlaskConicalOffIcon, FolderTreeIcon, InfoIcon, MessageSquareOffIcon, @@ -51,6 +52,7 @@ import { canEditPullRequestComment, canResolvePullRequestThread, } from "@t3tools/client-runtime/pull-request-editing"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, @@ -80,7 +82,8 @@ import { useDiffWorkerPool } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; -import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; +import { useHideTestFiles } from "../diffs/useHideTestFiles"; +import { diffFileTreeEntries, orderDiffFilesLikeTree } from "../diffs/diffFileTree.logic"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -307,6 +310,7 @@ function PullRequestCodeTab({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -496,9 +500,22 @@ function PullRequestCodeTab({ ignoreWhitespace, ), ); - const files = parsedSlices.flatMap((parsed) => - parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + const loadedFiles = orderDiffFilesLikeTree( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + ), ); + const testFileCount = loadedFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // A requested file stays visible so a jump to a hidden test still lands. + const files = hideTestFiles + ? loadedFiles.filter((file) => { + const path = resolveFileDiffPath(file); + return path === revealRequest?.path || !isTestChangedFile(path); + }) + : loadedFiles; + const hiddenTestFileCount = loadedFiles.length - files.length; const filePaths = files.map((file) => resolveFileDiffPath(file)); const omittedFileStats = new Map( loadedSlices.flatMap((slice) => @@ -1071,6 +1088,11 @@ function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} + {hiddenTestFileCount > 0 ? ( + + ) : null} {filesViewed.enabled && files.length > 0 ? ( @@ -1283,6 +1305,26 @@ function PullRequestCodeTab({ ) : null} + {testFileCount > 0 ? ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles ? "Show test files" : "Hide test files"} + + + ) : null} {fileKeys.length > 0 ? ( = { artwork: "Artwork", @@ -273,6 +274,7 @@ export function AppearanceSettingsPanel() { } /> + (null); - const [originalHost, setOriginalHost] = useState(null); - const [busy, setBusy] = useState(false); - const targets = environments.map((environment) => ({ - environmentId: environment.environmentId, - label: settingsScopeEnvironmentLabel(environment, environments), - connected: environment.connection.phase === "connected", - })); - const { checks, testConnection } = useHostConnectionChecks(targets); - const hostEnvironments = connectedEnvironments.map((environment) => ({ - environmentId: environment.environmentId, - label: environment.label, - labels: settingsScopeEnvironmentLabels(environment, connectedEnvironments), - hosts: environment.serverConfig?.settings.deviceHosts ?? [], - })); - const groupedHosts = groupDeviceHosts(hostEnvironments); - const save = async (host: SshDeviceHostConfig, remove = false, original = host) => { - if (!props.environmentId || projectScope) return; - setBusy(true); - const results = await Promise.allSettled( - environments.map(async (environment) => { - if (environment.connection.phase !== "connected" || !environment.serverConfig) { - throw new Error("Environment disconnected"); - } - if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) { - throw new Error("This connection cannot change device settings."); - } - return update({ - environmentId: environment.environmentId, - input: { - patch: { - deviceHosts: updateDeviceHosts( - environment.serverConfig.settings.deviceHosts, - host, - remove, - original, - ), - }, - }, - }); - }), - ); - setBusy(false); - const failed = environments.filter((_, index) => { - const result = results[index]; - return result?.status !== "fulfilled" || result.value._tag === "Failure"; - }); - if (failed.length === 0) { - setEditing(null); - } else { - toastManager.add({ - type: "error", - title: "Device hosts not saved on all environments", - description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`, - }); - } - }; - const listActions = { - busy: !canConfigure || projectScope || busy, - checks, - testConnection: async (host: SshDeviceHostConfig) => { - const results = await testConnection(host); - if (!results) return; - const failed = targets.filter((target) => results[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 - ? `Could not connect from ${failed.map((target) => target.label).join(", ")}.` - : "Connected or already available locally on each selected environment.", - }); - return results; - }, - onEdit: (host: SshDeviceHostConfig) => { - setOriginalHost(host); - setEditing(host); - }, - onRemove: (host: SshDeviceHostConfig) => void save(host, true), - }; - return ( - { - setOriginalHost(null); - setEditing({ kind: "ssh", id: randomUUID(), label: "", target: "" }); - }} - > - Add host - - } - > -
- {!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..09ccc32fd61b9 --- /dev/null +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -0,0 +1,802 @@ +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), + // A blank token can still resolve to the machine's own, so the host's answer decides. + status: 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/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 0bfa19498f6d9..43c4419962a02 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -77,6 +77,14 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ); const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); + const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); + const fallbackSelection = settings.fallbackModelSelection ?? selection; + const fallbackModelOptions = getCustomModelOptionsByInstance( + settings, + providers, + fallbackSelection?.instanceId, + fallbackSelection?.model, + ); const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); @@ -216,6 +224,59 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting /> ); + const fallbackRow = !isProjectScope ? ( + updateSettings({ fallbackModelSelection: null })} + /> + ) : null + } + control={ + fallbackSelection ? ( + { + const reason = modelDisabledReason(instanceId, model); + if (reason) { + toastManager.add({ + type: "error", + title: "Fallback model not saved", + description: reason, + }); + return; + } + updateSettings({ fallbackModelSelection: createModelSelection(instanceId, model) }); + }} + /> + ) : ( + No providers available + ) + } + /> + ) : null; + return ( {modelRow} + {fallbackRow} {workspaceRow} ) : category === "general" ? ( <> {modelRow} + {fallbackRow} = { turn: "Wait for the full response", @@ -142,39 +151,38 @@ function AboutVersionTitle() { function AboutVersionSection() { const updateState = useDesktopUpdateState(); - const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); + const [switchChannel, setSwitchChannel] = useState(null); const [isUpdateActionPending, setIsUpdateActionPending] = useState(false); + const { environments } = useEnvironments(); + const sessionStates = useAtomValue( + environmentSessionStatesAtom( + environmentIdsKey(environments.map(({ environmentId }) => environmentId)), + ), + ); + // A second run would replace the first run's progress and local download. + const updateRun = useUpdateEverythingRun(); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; - - const handleUpdateChannelChange = (channel: DesktopUpdateChannel) => { - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.setUpdateChannel !== "function" || - channel === selectedUpdateChannel - ) { - return; - } - - setIsChangingUpdateChannel(true); - void bridge - .setUpdateChannel(channel) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not change update track", - description: error instanceof Error ? error.message : "Update track change failed.", - }), - ); - }) - .finally(() => { - setIsChangingUpdateChannel(false); - }); - }; + // A switch that failed part way leaves this app or a server on the other track. + // With its updater off, a switch cannot move this app, so it is not counted. + const appOffTrack = + updateState?.enabled !== false && desktopUpdateChannelOf(APP_VERSION) !== selectedUpdateChannel; + const unswitchedMachines = + (appOffTrack ? 1 : 0) + + environments.filter( + (environment, index) => + environment.connection.phase === "connected" && + canUpdateServer(sessionStates[index]!) && + awaitsTrackSwitch( + { + serverConfig: environment.serverConfig, + hostedByThisApp: isHostedByThisApp(environment), + }, + selectedUpdateChannel, + ), + ).length; const handleButtonClick = async () => { const bridge = window.desktopBridge; @@ -272,7 +280,8 @@ function AboutVersionSection() { const statusLabel: Record = { checking: "Checking…", downloading: "Downloading…", - "up-to-date": "Up to Date", + // Off its track, "up to date" only means no switch is pending; Review moves it. + ...(appOffTrack ? {} : { "up-to-date": "Up to Date" }), }; const buttonLabel = actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; @@ -308,23 +317,43 @@ function AboutVersionSection() { {hasDesktopBridge ? ( 0 ? ( + <> + {unswitchedMachines === 1 + ? "1 machine still runs" + : `${unswitchedMachines} machines still run`}{" "} + {channelSwitchLabel(selectedUpdateChannel === "nightly" ? "latest" : "nightly")}.{" "} + {/* The dialog moves what it can and says why it skips the rest. */} + + + ) : ( + "Switches this app and every connected machine whose database the target release supports." + ) + } control={ { + const value = Number(event.target.value); + setDraft((current) => ({ ...current, [field]: value })); + }} + /> + + ); + + 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, or leave it blank to + add it later. A blank token uses the machine's{" "} + AGENT_DEVICE_DAEMON_AUTH_TOKEN when its{" "} + AGENT_DEVICE_DAEMON_BASE_URL is this gateway. +

+
+
+ {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 || !gatewayUrl ? null : ( +

+ {problem} +

+ )} +
+ + + + +
+
+
+ ); +} + +function SimlockTokenField(props: { + environmentId: EnvironmentId; + hostId: string | null; + gatewayUrl: string | null; + label: ReactNode; + token: SimlockTokenEntry; + hasSaved: boolean; + busy: boolean; + onChange: (token: SimlockTokenEntry) => void; +}) { + const id = useId(); + const entered = props.token.kind === "new" ? props.token.value.trim() : ""; + // A cleared field asks about the machine's own token, which a blank one falls back to. + const check = useAtomValue( + props.gatewayUrl === null + ? NO_CHECK + : deviceSimlockIdentityCheck({ + environmentId: props.environmentId, + input: { + gatewayUrl: props.gatewayUrl, + ...(entered + ? { agentToken: entered } + : props.token.kind === "saved" && props.hostId !== null + ? { hostId: props.hostId } + : {}), + }, + }), + ); + const identity = AsyncResult.isSuccess(check) ? check.value : null; + const status = + props.gatewayUrl === null ? null : check._tag === "Initial" || check.waiting ? ( + Checking… + ) : identity ? ( + + {identity.tokenSource === "env" + ? `Using this machine's agent-device token (${identity.signedInAs}).` + : `Accepted as ${identity.signedInAs}.`} + + ) : AsyncResult.isFailure(check) ? ( + + {failureMessage(check.cause)} + + ) : null; + 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} +
+ {status ?

{status}

: null} +
+ ); +} diff --git a/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx b/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx new file mode 100644 index 0000000000000..a142155d0357d --- /dev/null +++ b/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx @@ -0,0 +1,53 @@ +import { ChevronDownIcon } from "lucide-react"; + +import { updateClientSettings, useClientSettings } from "../../hooks/useSettings"; +import { ThreadDetailsItemsMenuItems } from "../chat/ThreadDetailsItemsMenu"; +import { + isDefaultThreadDetailsVisibility, + resolveThreadDetailsVisibility, + RESET_THREAD_DETAILS_ITEMS, + THREAD_DETAILS_ITEMS, +} from "../chat/threadDetailsItems"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +export function ThreadDetailsItemsRow() { + const visibility = useClientSettings(resolveThreadDetailsVisibility); + const enabled = THREAD_DETAILS_ITEMS.filter((item) => visibility[item.id]).length; + return ( + updateClientSettings(RESET_THREAD_DETAILS_ITEMS)} + /> + ) + } + control={ + + + } + > + {enabled} of {THREAD_DETAILS_ITEMS.length} on + + + + + + + } + /> + ); +} diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx new file mode 100644 index 0000000000000..f746cff28c270 --- /dev/null +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -0,0 +1,178 @@ +import { useAtomValue } from "@effect/atom-react"; +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { DesktopUpdateChannel } from "@t3tools/contracts"; +import { acceptsTrackSwitchRelease, desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { isMacPlatform } from "@t3tools/shared/keybindings"; +import { useState } from "react"; +import { AsyncResult } from "effect/reactivity"; + +import { APP_VERSION } from "~/branding"; +import { useDesktopUpdateState } from "~/state/desktopUpdate"; +import { useEnvironments } from "~/state/environments"; +import { npmReleaseAtom } from "~/state/npmRelease"; +import { downloadLocalUpdate, installLocalUpdate } from "../sidebar/SidebarUpdatePill"; +import { UpdateEverythingDialog } from "../sidebar/UpdateEverythingDialog"; +import { + channelSwitchLabel, + localSwitchBlockReason, + type ChannelSwitchRelease, +} from "../sidebar/updateEverything.logic"; +import { isHostedByThisApp, useUpdateEverything } from "../sidebar/useUpdateEverything"; +import { prepareLocalTrackSwitch } from "./updateTrackSwitch.logic"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; + +/** Moves this app and every connected machine to `channel`. */ +export function UpdateTrackSwitchDialog({ + channel, + onClose, +}: { + readonly channel: DesktopUpdateChannel; + readonly onClose: () => void; +}) { + // npm dist-tags match the desktop channel names. + const release = useAtomValue(npmReleaseAtom(channel)); + if (AsyncResult.isSuccess(release) && release.value !== null) { + return ; + } + const label = channelSwitchLabel(channel); + return ( + !open && onClose()}> + + + Switch to {label} + + {AsyncResult.isFailure(release) || AsyncResult.isSuccess(release) + ? `Could not find the latest ${label} release on npm. Try again later.` + : "Checking releases…"} + + + + }>Cancel + + + + ); +} + +function SwitchPlan({ + release, + onClose, +}: { + readonly release: ChannelSwitchRelease; + readonly onClose: () => void; +}) { + const updateState = useDesktopUpdateState(); + const { environments } = useEnvironments(); + const crossesLocal = desktopUpdateChannelOf(APP_VERSION) !== release.channel; + // With its updater off (a .deb install, or no feed) this app stays put and only remotes move. + const updatesOff = updateState?.enabled === false; + // After a partial switch this app may already be on the track; then only remotes move. + const switchesLocal = crossesLocal && !updatesOff; + const [checking, setChecking] = useState(false); + const everything = useUpdateEverything(switchesLocal ? release.version : null, release); + const label = channelSwitchLabel(release.channel); + // This app's reinstall restarts every server it hosts, so each of their databases must allow it. + const localBlocked = localSwitchBlockReason( + APP_VERSION, + release, + environments.filter(isHostedByThisApp).map((environment) => ({ + connected: environment.connection.phase === "connected", + databaseMigration: environment.serverConfig?.environment.databaseMigration, + })), + window.desktopBridge?.getLocalEnvironmentBootstraps().length ?? 0, + ); + + const run = async () => { + const bridge = window.desktopBridge; + if (!bridge) return; + // Stay open while the feed is checked; the run's own progress takes over after. + setChecking(true); + const fail = (description: string) => + toastManager.add( + stackedThreadToast({ type: "error", title: `Could not switch to ${label}`, description }), + ); + const prepared = await prepareLocalTrackSwitch(bridge, { + channel: release.channel, + version: release.version, + reinstall: switchesLocal, + }); + onClose(); + if (!prepared.ok) { + fail(prepared.message); + return; + } + const { local } = prepared; + const result = await settlePromise(() => + everything.run( + local === null + ? {} + : { + ...(local.status === "available" + ? { + downloadLocal: (reportError) => + downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), + } + : {}), + // Install only a build the database check covers, pinned so nothing replaces it. + installLocal: async (reportError) => { + const { downloadedVersion } = await bridge.getUpdateState(); + if ( + downloadedVersion === null || + !acceptsTrackSwitchRelease(downloadedVersion, release.channel, release.version) + ) { + reportError( + "Could not install update", + `This app downloaded ${downloadedVersion ?? "nothing"}, not ${release.version} or newer.`, + ); + return false; + } + return installLocalUpdate(bridge, reportError, downloadedVersion); + }, + }, + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + fail(error instanceof Error ? error.message : "An unexpected error occurred."); + } + }; + + return ( + !open && !checking && onClose()} + title={`Switch to ${label}?`} + version={release.version} + providerCount={0} + rows={everything.rows} + notes={[ + ...(everything.rows.length === 0 && !crossesLocal + ? [`Every connected machine is already on ${label}. This only saves the track.`] + : []), + ...(crossesLocal && updatesOff + ? ["This app's updates are off here, so reinstall it yourself to switch."] + : []), + ...(switchesLocal && isMacPlatform(navigator.platform) + ? ["If T3 Code does not reopen after it restarts, open it again."] + : []), + ]} + restarts={everything.restarts} + confirmLabel={checking ? "Checking…" : `Switch to ${label}`} + busy={checking} + disabledReason={ + !switchesLocal || localBlocked === null ? null : `This app cannot switch: ${localBlocked}.` + } + onConfirm={() => void run()} + /> + ); +} 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..1d5c13e72a061 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -315,6 +315,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["wide full width column layout messages composer monitor"], }, + { + id: "thread-details-items", + title: "Thread details", + to: "/settings/appearance", + searchTerms: [ + "customize show hide panel card environment editor scripts branch pull request git changes servers automations lineage usage limits 5 hour weekly quota tokens context window", + ], + }, { id: "effort-display", title: "Effort display", @@ -451,6 +459,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 +821,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..2efe3c78c24eb --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts @@ -0,0 +1,72 @@ +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, if it has one", () => { + 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({ + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "" }] }, + ], + }); + 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..a4ef1e1bb90b1 --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts @@ -0,0 +1,93 @@ +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, which may stay blank until later. Saved tokens arrive redacted; sending one back keeps it. + */ +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() : ""; + patches.push({ + environmentId: environment.environmentId, + hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false), + }); + } + return { patches }; +} diff --git a/apps/web/src/components/settings/updateTrackSwitch.logic.test.ts b/apps/web/src/components/settings/updateTrackSwitch.logic.test.ts new file mode 100644 index 0000000000000..63c4422f28a64 --- /dev/null +++ b/apps/web/src/components/settings/updateTrackSwitch.logic.test.ts @@ -0,0 +1,76 @@ +import type { DesktopUpdateChannel, DesktopUpdateState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { prepareLocalTrackSwitch } from "./updateTrackSwitch.logic"; + +const STABLE = "0.1.3"; + +/** Like the desktop app, saving a track re-checks the feed and reports what it offers. */ +function fakeBridge(offer: string | null | Error) { + const saved: DesktopUpdateChannel[] = []; + const base: DesktopUpdateState = { + enabled: true, + status: "up-to-date", + channel: "nightly", + currentVersion: "0.1.4-nightly.20261027.3796", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }; + return { + saved, + bridge: { + setUpdateChannel: async (channel: DesktopUpdateChannel) => { + saved.push(channel); + if (offer instanceof Error) throw offer; + return { + ...base, + channel, + status: offer === null ? ("up-to-date" as const) : ("available" as const), + availableVersion: offer, + }; + }, + }, + }; +} + +const prepare = (bridge: ReturnType["bridge"], reinstall = true) => + prepareLocalTrackSwitch(bridge, { channel: "latest", version: STABLE, reinstall }); + +describe("prepareLocalTrackSwitch", () => { + it("installs the checked release or a newer one on the track, never an older one", async () => { + expect(await prepare(fakeBridge(STABLE).bridge)).toMatchObject({ ok: true }); + // A newer Stable knows every migration the checked one does. + expect(await prepare(fakeBridge("0.1.4").bridge)).toMatchObject({ ok: true }); + + const stale = fakeBridge("0.1.2"); + expect(await prepare(stale.bridge)).toEqual({ + ok: false, + message: "This app found 0.1.2, which is older than 0.1.3. Try again in a few minutes.", + }); + // The saved track is intent; "Switch them" retries without re-picking it. + expect(stale.saved).toEqual(["latest"]); + }); + + it("reports a track that could not be saved", async () => { + expect(await prepare(fakeBridge(new Error("feed check failed")).bridge)).toEqual({ + ok: false, + message: "feed check failed", + }); + }); + + it("only saves the track when this app is already on it", async () => { + const undo = fakeBridge(null); + expect(await prepare(undo.bridge, false)).toEqual({ ok: true, local: null }); + expect(undo.saved).toEqual(["latest"]); + }); +}); diff --git a/apps/web/src/components/settings/updateTrackSwitch.logic.ts b/apps/web/src/components/settings/updateTrackSwitch.logic.ts new file mode 100644 index 0000000000000..6e7e49898df0b --- /dev/null +++ b/apps/web/src/components/settings/updateTrackSwitch.logic.ts @@ -0,0 +1,50 @@ +import type { DesktopBridge, DesktopUpdateChannel, DesktopUpdateState } from "@t3tools/contracts"; +import { acceptsTrackSwitchRelease } from "@t3tools/shared/cliRelease"; + +type TrackSwitchBridge = Pick; + +/** Saves the track, then, when this app must reinstall, finds a build the database check covers. + * The saved track is the user's intent and stays even when no such build is ready yet: a track + * saved toward Stable never offers an older build outside a guarded switch, and "Switch them" + * retries. Saving when nothing reinstalls lets picking the old track undo a partial switch. */ +export async function prepareLocalTrackSwitch( + bridge: TrackSwitchBridge, + input: { + readonly channel: DesktopUpdateChannel; + readonly version: string; + readonly reinstall: boolean; + }, +): Promise< + | { readonly ok: true; readonly local: DesktopUpdateState | null } + | { readonly ok: false; readonly message: string } +> { + let local: DesktopUpdateState; + try { + // A build on another track re-checks the feed, older releases allowed down to the checked one. + local = await bridge.setUpdateChannel( + input.channel, + input.reinstall ? input.version : undefined, + ); + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : "The update track did not change.", + }; + } + if (!input.reinstall) return { ok: true, local: null }; + const found = local.downloadedVersion ?? local.availableVersion; + if ( + (local.status === "available" || local.status === "downloaded") && + found !== null && + acceptsTrackSwitchRelease(found, input.channel, input.version) + ) { + return { ok: true, local }; + } + return { + ok: false, + message: + found !== null + ? `This app found ${found}, which is older than ${input.version}. Try again in a few minutes.` + : (local.message ?? `This app found no build of ${input.version} to install.`), + }; +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ec161e9998548..e233a4144d435 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -223,19 +223,15 @@ const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { /> )} + ); }); -export const SidebarChromeFooter = memo(function SidebarChromeFooter({ - showUndoNotice = false, -}: { - showUndoNotice?: boolean; -}) { +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - {showUndoNotice ? : null} diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 30a6776837eeb..104bda5582b4d 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -1,7 +1,7 @@ import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { type ReactNode } from "react"; +import { type ReactNode, useLayoutEffect } from "react"; import { cn } from "~/lib/utils"; import { animateSidebarLayoutChanges, @@ -9,19 +9,25 @@ import { type SidebarListMarker, } from "../Sidebar.logic"; import { SIDEBAR_SORTABLE_DRAG_DISABLED } from "./SidebarThreadRow"; -import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; function SortableSidebarMarker(props: { marker: SidebarListMarker; className?: string; children?: ReactNode; + revealOverflow?: boolean; "data-testid"?: string; }) { - const { setNodeRef, transform, transition } = useSortable({ + const { node, setNodeRef, transform, transition } = useSortable({ id: sidebarMarkerId(props.marker), disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, animateLayoutChanges: animateSidebarLayoutChanges, }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the projected drop box inside the scroll viewport as drag transforms move it + useLayoutEffect(() => { + if (props.revealOverflow && transform?.y !== undefined) { + node.current?.firstElementChild?.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + }, [node, props.revealOverflow, transform?.y]); return (
  • {props.showHint ? (
    void }; - /** The first shelf at the bottom of the list hosts the undo notice just above itself. */ - hostsUndoNotice?: boolean; }) { const shelf = props.marker === "working-header" @@ -124,9 +129,6 @@ export function SidebarSectionHeader(props: { data-testid={`sidebar-${props.marker}`} className={cn("relative mx-0.5 h-8", props.className)} > - {props.hostsUndoNotice ? ( - - ) : null} state.notice); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const summary = notice ? threadUndoNoticeSummary(notice) : null; + // Short enough to fit beside the utility icons; screen readers hear the full summary. + const summary = notice + ? notice.count === 1 + ? notice.action + : `${notice.action} ${notice.count}` + : null; // Keeps the last text on screen while it fades out, then unmounts on transitionend. const [shownSummary, setShownSummary] = useState(summary); @@ -23,21 +29,23 @@ export function SidebarThreadUndoNotice({ className }: { className?: string }) { if (!text) return null; return ( -
    { - if (!summary && event.target === event.currentTarget) setShownSummary(null); - }} - className={cn( - "flex gap-1 px-2 text-2xs leading-6 text-sidebar-muted-foreground transition-opacity duration-200 ease-out starting:opacity-0", - !summary && "opacity-0", - className, - )} - > - {text}, - {threadUndoLabel(keybindings)} -
    + // No min-w-0: the undo button sets this item's minimum, so a shared row squeezes its neighbour. + +
    { + if (!summary && event.target === event.currentTarget) setShownSummary(null); + }} + // Reversed wrap: a summary that does not fit beside the undo button drops to a + // second line that overflow hides, instead of truncating mid-word. Browse mode + // reads the button first; the live announcement carries the full sentence. + className={cn( + "flex h-6 min-w-0 flex-1 flex-row-reverse flex-wrap gap-x-1 overflow-hidden text-2xs leading-6 whitespace-nowrap text-sidebar-muted-foreground transition-opacity duration-200 ease-out starting:opacity-0", + !summary && "opacity-0", + )} + > + {threadUndoLabel(keybindings)} + {text}, +
    +
    ); } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 714d1564bac0a..18ebd70c6162c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -81,7 +81,7 @@ function publish(update: DesktopUpdateState | null) { }); } -const releaseNotes = () => document.querySelector('[aria-label="Nightly update release notes"]'); +const releaseNotes = () => document.querySelector('[aria-label="Update release notes"]'); describe("SidebarUpdatePill release notes", () => { it("closes when the release notes stop applying", async () => { diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index ecfeb4d664a3b..7e0ee498cece5 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -53,7 +53,7 @@ function shouldUseSidebarUpdateReleaseNotesPopover( showUpdateDetails: boolean, state: DesktopUpdateState | null, ): boolean { - return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0; + return showUpdateDetails && (state?.releaseNotes.length ?? 0) > 0; } function handleSidebarUpdateReleaseNotesPopoverOpenChange( @@ -134,7 +134,7 @@ const toastUpdateError: ReportLocalUpdateError = (title, description) => { toastManager.add(stackedThreadToast({ type: "error", title, description })); }; -async function downloadLocalUpdate( +export async function downloadLocalUpdate( bridge: DesktopBridge, { notifyDownloaded, @@ -160,12 +160,13 @@ async function downloadLocalUpdate( } } -async function installLocalUpdate( +export async function installLocalUpdate( bridge: DesktopBridge, reportError: ReportLocalUpdateError = toastUpdateError, + expectedVersion?: string, ): Promise { try { - const result = await bridge.installUpdate(); + const result = await bridge.installUpdate(expectedVersion); if (!result.accepted) { showDesktopUpdateDownloadedToast(bridge, result.state); return false; @@ -541,7 +542,7 @@ function SidebarUpdateControl() { {showReleaseNotesPopover && state ? ( { if ( diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index b9f29741bfa63..974f81c8d76b3 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -5,8 +5,8 @@ import { getDesktopUpdateReleaseHistoryUrl, getDesktopUpdateReleaseUrl, } from "../desktopUpdate.logic"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast"; -import { Separator } from "../ui/separator"; type DesktopUpdateShell = Pick; @@ -19,6 +19,18 @@ function keyReleaseNoteItems(items: ReadonlyArray) { }); } +/** Feed times read "3h ago"; build days from a prerelease version read "today" or "2d ago". */ +function formatReleaseAge(releasedAt: string): string { + const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(releasedAt); + if (!day) return formatRelativeTimeLabel(releasedAt); + const now = new Date(); + const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const days = Math.round( + (today - Date.UTC(Number(day[1]), Number(day[2]) - 1, Number(day[3]))) / 86_400_000, + ); + return days <= 0 ? "today" : `${days}d ago`; +} + function ReleaseLink({ children, releaseUrl, @@ -30,7 +42,7 @@ function ReleaseLink({ }) { return ( { event.preventDefault(); @@ -38,7 +50,7 @@ function ReleaseLink({ }} > {children} - + ); } @@ -52,12 +64,15 @@ export function SidebarUpdateReleaseNotes({ readonly state: DesktopUpdateState; readonly tooltip: string; }) { - if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + if (state.releaseNotes.length === 0) { return <>{tooltip}; } + const releasedAt = state.releaseNotes.find( + (releaseNote) => releaseNote.version === state.availableVersion, + )?.releasedAt; return ( -
    +
    {state.status === "available" ? (
    @@ -66,7 +81,9 @@ export function SidebarUpdateReleaseNotes({
    {state.availableVersion ? (
    - {state.availableVersion} + {releasedAt + ? `${state.availableVersion} · ${formatReleaseAge(releasedAt)}` + : state.availableVersion}
    ) : null}
    @@ -74,18 +91,40 @@ export function SidebarUpdateReleaseNotes({
    {tooltip}
    )}
    -
    +
    {state.releaseNotes.map((releaseNote, index) => { const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version); const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length); const linkLabel = - omittedItemCount === 0 + releaseNote.stats ?? + (omittedItemCount === 0 ? "View release on GitHub" - : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`; + : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`); + + if (releaseNote.intro !== undefined) { + return ( +
    0 ? "mt-4" : undefined} key={releaseNote.version}> + {releaseNote.version !== state.availableVersion ? ( +

    + {releaseNote.releasedAt + ? `${releaseNote.version} · ${formatReleaseAge(releaseNote.releasedAt)}` + : releaseNote.version} +

    + ) : null} +

    + {releaseNote.intro} +

    + {releaseUrl ? ( + + {releaseNote.stats ?? "View release on GitHub"} + + ) : null} +
    + ); + } return ( -
    - {index > 0 && } +
    0 ? "mt-4" : undefined} key={releaseNote.version}>

    {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} @@ -107,8 +146,7 @@ export function SidebarUpdateReleaseNotes({ ); })} {state.omittedReleaseCount > 0 ? ( -
    - +
    {`${state.omittedReleaseCount} older ${state.omittedReleaseCount === 1 ? "release" : "releases"} on GitHub`} diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index 8cd5c5ca777d5..9bf101b6fa4e7 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -47,6 +47,14 @@ interface UpdateEverythingDialogProps { readonly rows: ReadonlyArray; readonly restarts: ReadonlyArray; readonly onConfirm: () => void; + /** Overrides the "Update N machines?" title, for an update-track switch. */ + readonly title?: string; + readonly confirmLabel?: string; + readonly notes?: ReadonlyArray; + /** Shown in place of running-thread counts; disables the confirm button. */ + readonly disabledReason?: string | null; + /** The confirmed work is starting; it cannot be cancelled from here. */ + readonly busy?: boolean; } type UpdateEverythingDialogContentProps = Omit< @@ -89,6 +97,11 @@ function UpdateEverythingDialogContent({ restarts, onConfirm, cancelRef, + title, + confirmLabel = "Update", + notes = [], + disabledReason = null, + busy = false, }: UpdateEverythingDialogContentProps) { const stopped = useAtomValue( runningThreadCountAtom( @@ -116,7 +129,7 @@ function UpdateEverythingDialogContent({ <> - Update {updating.length} {updating.length === 1 ? "machine" : "machines"}? + {title ?? `Update ${updating.length} ${updating.length === 1 ? "machine" : "machines"}?`} {version !== null ? ( @@ -132,6 +145,11 @@ function UpdateEverythingDialogContent({
    + {notes.map((note) => ( +

    + {note} +

    + ))} {/* Machine names truncate first. Provider lists wrap between names, never inside one. */}
    @@ -196,7 +214,11 @@ function UpdateEverythingDialogContent({
    - {running > 0 ? ( + {disabledReason !== null ? ( +

    + {disabledReason} +

    + ) : running > 0 ? (

    {stopped === 0 ? `${threads} will be interrupted and can resume after the restart.` @@ -205,10 +227,12 @@ function UpdateEverythingDialogContent({ : `${threads} will be interrupted. ${continuing} can resume after the restart.`}

    ) : null} - }> + }> Cancel - +
    ); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 3739308f7b1c7..62aef93572c4d 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -2,8 +2,11 @@ import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contrac import { describe, expect, it } from "vite-plus/test"; import { + collectChannelSwitchTargets, collectRestartingMachines, + localSwitchBlockReason, collectServerUpdateTargets, + describeUpdateCapsule, describeUpdateEverything, runUpdateEverything, type UpdateEverythingMachine, @@ -27,7 +30,9 @@ function machine( capabilities: { repositoryIdentity: true, ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}), - ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true } : {}), + ...(selfUpdate === "desktop-managed" + ? { desktopAppUpdate: true, desktopAppChannelSwitch: true } + : {}), }, }, settings: { continueThreadsAfterServerUpdate: false }, @@ -64,6 +69,7 @@ describe("collectServerUpdateTargets", () => { machine("manual", "0.0.40", undefined), ], "0.0.50", + true, ); expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ @@ -79,6 +85,154 @@ describe("collectServerUpdateTargets", () => { ["manual", "needs a manual update"], ]); }); + + it("waits for npm only for hosts that install from it or when the release may lack archives", () => { + const hosts = [ + machine("archive-service", "0.0.41-nightly.20260914.1700", "boot-service"), + machine("npm-service", "0.0.41-nightly.20260914.1699", "boot-service"), + machine("legacy-respawn", "0.1.4-nightly.20261027.3796", "respawn"), + machine("desktop", "0.1.4-nightly.20261027.3796", "desktop-managed"), + ]; + const waits = (releaseArchivesPublished: boolean) => + collectServerUpdateTargets( + hosts, + "0.1.5-nightly.20261029.3873", + releaseArchivesPublished, + ).waitForNpm.map((target) => target.serverLabel); + + expect(waits(true)).toEqual(["npm-service", "legacy-respawn"]); + expect(waits(false)).toEqual(["archive-service", "npm-service", "legacy-respawn"]); + }); +}); + +describe("collectChannelSwitchTargets", () => { + const NIGHTLY = "0.1.4-nightly.20261027.3796"; + const OLD_NIGHTLY = "0.1.3-nightly.20261001.3500"; + const NEWER_NIGHTLY = "0.1.4-nightly.20261020.3700"; + // What each server reports as its database's highest migration. + const host = ( + base: UpdateEverythingMachine, + databaseMigration: number | undefined, + os: "linux" | "darwin" = "linux", + ): UpdateEverythingMachine => ({ + ...base, + serverConfig: { + ...base.serverConfig!, + environment: { + ...base.serverConfig!.environment, + platform: { os, arch: "x64" }, + ...(databaseMigration === undefined ? {} : { databaseMigration }), + }, + }, + }); + + it("moves nightly hosts to stable only when stable knows their database", () => { + const stable = { channel: "latest" as const, version: "0.1.3", databaseMigration: 69 }; + const { targets, skipped } = collectChannelSwitchTargets( + [ + host(machine("this-app", NIGHTLY, "desktop-managed", { hostedByThisApp: true }), 72), + host(machine("old-service", OLD_NIGHTLY, "boot-service"), 69), + // Service launchers refuse older versions, even when the database allows it. + host(machine("newer-service", NEWER_NIGHTLY, "boot-service"), 69), + host(machine("linux-desktop", OLD_NIGHTLY, "desktop-managed"), 69), + host(machine("new-service", NIGHTLY, "boot-service"), 72), + host(machine("old-server", "0.1.2-nightly.20260901.3000", "boot-service"), undefined), + host(machine("mac-desktop", OLD_NIGHTLY, "desktop-managed"), 69, "darwin"), + host(machine("preview", "0.1.3-preview.20261026.3702", "boot-service"), 69), + host(machine("stable", "0.1.3", "boot-service"), 69), + host(machine("stable-desktop", "0.1.2", "desktop-managed"), 68), + // Already current, but its saved track may still say Nightly. + host(machine("current-desktop", "0.1.3", "desktop-managed"), 69, "darwin"), + ], + stable, + ); + + expect( + targets.map((target) => [ + target.serverLabel, + target.targetVersion, + target.channel, + target.trackOnly ?? false, + ]), + ).toEqual([ + ["old-service", "0.1.3", undefined, false], + ["linux-desktop", "0.1.3", "latest", false], + ["stable-desktop", "0.1.3", "latest", false], + ["current-desktop", "0.1.3", "latest", true], + ]); + expect(targets.every((target) => target.targetDatabaseMigration === 69)).toBe(true); + expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ + ["newer-service", "run `t3 update --channel stable --allow-downgrade` there"], + [ + "new-service", + "its database has changes 0.1.3 does not support yet; stay on Nightly until Stable has them", + ], + ["old-server", "its server predates database checks; update it on Nightly first"], + ["mac-desktop", "switch the Mac app on that Mac"], + ["preview", "runs a preview build"], + ]); + }); + + it("moves stable hosts to nightly only when nightly knows their database", () => { + const nightly = { channel: "nightly" as const, version: NIGHTLY, databaseMigration: 72 }; + const { targets, skipped } = collectChannelSwitchTargets( + [ + host(machine("service", "0.1.2", "boot-service"), 70), + // A Stable tag built a migration the latest Nightly does not have yet. + host(machine("ahead", "0.1.3", "boot-service"), 73), + machine("old-desktop", "0.1.2", "desktop-managed", { + serverConfig: { + ...machine("old-desktop", "0.1.2", "desktop-managed").serverConfig!, + environment: { + ...machine("old-desktop", "0.1.2", "desktop-managed").serverConfig!.environment, + capabilities: { repositoryIdentity: true, serverSelfUpdate: "desktop-managed" }, + }, + }, + }), + machine("current", NIGHTLY, "boot-service"), + ], + nightly, + ); + + expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ + ["service", NIGHTLY], + ]); + expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ + [ + "ahead", + `its database has changes ${NIGHTLY} does not support yet; stay on Stable until Nightly has them`, + ], + ["old-desktop", "needs a newer desktop app"], + ]); + }); +}); + +describe("localSwitchBlockReason", () => { + it("checks every server this app hosts, and only while connected", () => { + const stable = { channel: "latest" as const, version: "0.1.3", databaseMigration: 69 }; + const nightly = "0.1.4-nightly.20261027.3796"; + + const windows = { connected: true, databaseMigration: 69 }; + expect(localSwitchBlockReason(nightly, stable, [windows], 1)).toBeNull(); + // A WSL server's newer migration blocks the reinstall that would restart it. + expect( + localSwitchBlockReason( + nightly, + stable, + [windows, { connected: true, databaseMigration: 72 }], + 2, + ), + ).toContain("does not support yet"); + // A WSL backend still starting has no client entry, so its database is unchecked. + expect(localSwitchBlockReason(nightly, stable, [windows], 2)).toBe( + "its server is not connected, so its database cannot be checked", + ); + // A cached head from a disconnected server proves nothing. + expect( + localSwitchBlockReason(nightly, stable, [{ connected: false, databaseMigration: 69 }], 1), + ).toBe("its server is not connected, so its database cannot be checked"); + expect(localSwitchBlockReason("0.1.3", stable, [], 1)).toBeNull(); + }); }); describe("collectRestartingMachines", () => { @@ -177,3 +331,33 @@ describe("describeUpdateEverything", () => { ]); }); }); + +describe("describeUpdateCapsule", () => { + it("sends failed track switches back through the planner instead of retrying their target", () => { + const failed = { + machine: { + environmentId: EnvironmentId.make("remote"), + label: "remote", + updatesProviders: false, + server: { + environmentId: EnvironmentId.make("remote"), + serverLabel: "remote", + selfUpdate: "boot-service" as const, + targetVersion: "0.1.3", + }, + handedOffVersion: null, + startedAt: 0, + result: "failed" as const, + failure: "offline", + providerFailure: null, + }, + status: "failed" as const, + }; + const run = { localVersion: null, phase: "servers" as const, restartAt: null, machines: [] }; + + expect(describeUpdateCapsule(run, [failed], null, 0).action).toBe("retry"); + expect(describeUpdateCapsule({ ...run, trackSwitch: true }, [failed], null, 0).action).toBe( + "dismiss", + ); + }); +}); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index 469e5a0faf674..de21c535f517c 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -1,6 +1,12 @@ import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; -import { cliReleaseChannelOf } from "@t3tools/shared/cliRelease"; +import type { + DesktopUpdateChannel, + EnvironmentId, + ServerConfig, + ServerSelfUpdateCapability, +} from "@t3tools/contracts"; +import { cliReleaseChannelOf, desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveServerSelfUpdateCapability, @@ -57,14 +63,25 @@ export interface SkippedMachine { readonly reason: string; } +/** Boot services before this build installed the server from npm, not from release archives. */ +const RELEASE_ARCHIVE_SELF_UPDATE_SINCE = "0.0.41-nightly.20260914.1700"; + /** Skip current and app-hosted servers; the latter update with the app and would relaunch mid-run. - * Desktop-managed servers use their own feed. */ + * Desktop-managed servers use their own feed. `waitForNpm` lists targets that need + * `t3@` on npm first. `releaseArchivesPublished` means the version came from the + * desktop feed: its GitHub Release goes public with the CLI archives that boot services download. */ export function collectServerUpdateTargets( machines: ReadonlyArray, targetVersion: string, -): { readonly targets: ServerUpdateTarget[]; readonly skipped: SkippedMachine[] } { + releaseArchivesPublished: boolean, +): { + readonly targets: ServerUpdateTarget[]; + readonly skipped: SkippedMachine[]; + readonly waitForNpm: ServerUpdateTarget[]; +} { const targets: ServerUpdateTarget[] = []; const skipped: SkippedMachine[] = []; + const waitForNpm: ServerUpdateTarget[] = []; for (const machine of machines) { const config = machine.serverConfig; if (machine.hostedByThisApp) continue; @@ -76,16 +93,9 @@ export function collectServerUpdateTargets( } const skip = (reason: string) => skipped.push({ environmentId: machine.environmentId, label: machine.label, reason }); - if (config === null || !machine.connected) { - skip("not connected"); - continue; - } - if (!machine.canMaintain) { - skip("no permission"); - continue; - } - if (machine.updateState.status === "running") { - skip("already updating"); + const unavailable = unavailableReason(machine); + if (config === null || unavailable !== null) { + skip(unavailable ?? "not connected"); continue; } const selfUpdate = resolveServerSelfUpdateCapability(config); @@ -106,18 +116,213 @@ export function collectServerUpdateTargets( skip(`on the ${cliReleaseChannelOf(serverVersion)} channel`); continue; } - targets.push({ + const target: ServerUpdateTarget = { environmentId: machine.environmentId, serverLabel: machine.label, selfUpdate, installation: config.environment.capabilities.serverInstallation, desktopAppUpdate, targetVersion, + }; + targets.push(target); + if ( + selfUpdate !== "desktop-managed" && + (!releaseArchivesPublished || + selfUpdate === "respawn" || + compareSemverVersions(serverVersion, RELEASE_ARCHIVE_SELF_UPDATE_SINCE) < 0) + ) { + waitForNpm.push(target); + } + } + return { targets, skipped, waitForNpm }; +} + +function unavailableReason(machine: UpdateEverythingMachine): string | null { + if (machine.serverConfig === null || !machine.connected) return "not connected"; + if (!machine.canMaintain) return "no permission"; + if (machine.updateState.status === "running") return "already updating"; + return null; +} + +/** The latest release on the track a fleet switches to, as npm reports it. */ +export interface ChannelSwitchRelease { + readonly channel: DesktopUpdateChannel; + readonly version: string; + readonly databaseMigration: number | undefined; +} + +export const channelSwitchLabel = (channel: DesktopUpdateChannel) => + channel === "nightly" ? "Nightly" : "Stable"; + +/** Stable and Nightly each build their own commits, so either can hold migrations the other lacks. + * Crossing tracks needs proof that the target release knows every migration the database holds. */ +const crossesTrack = (fromVersion: string, release: ChannelSwitchRelease) => + desktopUpdateChannelOf(fromVersion) !== release.channel; + +function channelSwitchBlockReason( + fromVersion: string, + release: ChannelSwitchRelease, + currentMigration: number | undefined, +): string | null { + if (!crossesTrack(fromVersion, release)) return null; + const from = channelSwitchLabel(desktopUpdateChannelOf(fromVersion)); + // Servers and releases from before this check report no migration number. + if (currentMigration === undefined) { + return `its server predates database checks; update it on ${from} first`; + } + if (release.databaseMigration === undefined) { + return `${release.version} predates database checks; wait for a newer ${channelSwitchLabel(release.channel)} release`; + } + return currentMigration > release.databaseMigration + ? `its database has changes ${release.version} does not support yet; stay on ${from} until ${channelSwitchLabel(release.channel)} has them` + : null; +} + +/** This app's reinstall restarts every server it hosts, so each of their databases must allow the + * switch. `backendCount` is the desktop's own inventory: a backend still starting has no client + * entry yet. A cached config can outlive its connection, so only a connected server's head counts. */ +export function localSwitchBlockReason( + appVersion: string, + release: ChannelSwitchRelease, + hosted: ReadonlyArray<{ + readonly connected: boolean; + readonly databaseMigration: number | undefined; + }>, + backendCount: number, +): string | null { + if (!crossesTrack(appVersion, release)) return null; + if ( + hosted.length === 0 || + hosted.length < backendCount || + hosted.some((server) => !server.connected) + ) { + return "its server is not connected, so its database cannot be checked"; + } + return ( + hosted + .map((server) => channelSwitchBlockReason(appVersion, release, server.databaseMigration)) + .find((reason) => reason !== null) ?? null + ); +} + +/** Servers move to the release; desktop-managed hosts reinstall their app on its track. A desktop + * host already current gets a track-only request, since its saved track may still differ. */ +export function collectChannelSwitchTargets( + machines: ReadonlyArray, + release: ChannelSwitchRelease, +): { readonly targets: ServerUpdateTarget[]; readonly skipped: SkippedMachine[] } { + const targets: ServerUpdateTarget[] = []; + const skipped: SkippedMachine[] = []; + for (const machine of machines) { + const config = machine.serverConfig; + if (machine.hostedByThisApp) continue; + const skip = (reason: string) => + skipped.push({ environmentId: machine.environmentId, label: machine.label, reason }); + const target = ( + selfUpdate: ServerSelfUpdateCapability, + targetVersion: string, + ): ServerUpdateTarget => ({ + environmentId: machine.environmentId, + serverLabel: machine.label, + selfUpdate, + installation: config?.environment.capabilities.serverInstallation, + desktopAppUpdate: config !== null && supportsDesktopAppUpdate(config), + targetVersion, + ...(selfUpdate === "desktop-managed" ? { channel: release.channel } : {}), + ...(release.databaseMigration === undefined + ? {} + : { targetDatabaseMigration: release.databaseMigration }), }); + const serverVersion = config?.environment.serverVersion; + const sameTrack = serverVersion !== undefined && isOnTrack(serverVersion, release.channel); + if ( + config !== null && + sameTrack && + resolveVersionMismatch(serverVersion, release.version) === null + ) { + const move = trackSwitchMove(config, true); + if ( + !("skip" in move) && + move.selfUpdate === "desktop-managed" && + unavailableReason(machine) === null + ) { + targets.push({ ...target(move.selfUpdate, serverVersion), trackOnly: true }); + } + continue; + } + const unavailable = unavailableReason(machine); + if (config === null || serverVersion === undefined || unavailable !== null) { + skip(unavailable ?? "not connected"); + continue; + } + const move = trackSwitchMove(config, sameTrack); + if ("skip" in move) { + skip(move.skip); + continue; + } + const blocked = channelSwitchBlockReason( + serverVersion, + release, + config.environment.databaseMigration, + ); + if (blocked !== null) { + skip(blocked); + continue; + } + // Service launchers only accept newer versions, so leaving for an older release is manual. + if ( + move.selfUpdate === "boot-service" && + compareSemverVersions(release.version, serverVersion) <= 0 + ) { + skip( + `run \`t3 update --channel ${release.channel === "nightly" ? "nightly" : "stable"} --allow-downgrade\` there`, + ); + continue; + } + targets.push(target(move.selfUpdate, release.version)); } return { targets, skipped }; } +/** A remote host on the other track that a switch can still move, for retrying a partial switch. */ +export function awaitsTrackSwitch( + machine: Pick, + channel: DesktopUpdateChannel, +): boolean { + const config = machine.serverConfig; + return ( + !machine.hostedByThisApp && + config !== null && + !isOnTrack(config.environment.serverVersion, channel) && + !("skip" in trackSwitchMove(config, false)) + ); +} + +function isOnTrack(version: string, channel: DesktopUpdateChannel): boolean { + return cliReleaseChannelOf(version) !== "preview" && desktopUpdateChannelOf(version) === channel; +} + +/** How a switch moves a host, or why it never can from here, whatever the release. */ +function trackSwitchMove( + config: NonNullable, + sameTrack: boolean, +): { readonly selfUpdate: ServerSelfUpdateCapability } | { readonly skip: string } { + if (cliReleaseChannelOf(config.environment.serverVersion) === "preview") { + return { skip: "runs a preview build" }; + } + const selfUpdate = resolveServerSelfUpdateCapability(config); + if (selfUpdate === null) return { skip: "needs a manual update" }; + if (selfUpdate !== "desktop-managed") return { selfUpdate }; + if (config.environment.capabilities.desktopAppChannelSwitch !== true) { + return { skip: "needs a newer desktop app" }; + } + // Squirrel.Mac renames the bundle for the new track and then cannot relaunch it. + if (!sameTrack && config.environment.platform.os === "darwin") { + return { skip: "switch the Mac app on that Mac" }; + } + return { selfUpdate }; +} + export interface UpdateEverythingSteps { readonly downloadLocal?: () => Promise; readonly updateProviders: () => Promise; @@ -163,6 +368,8 @@ export interface UpdateRunMachine { } export interface UpdateEverythingRun { + /** Track switches retry through their planner, which re-checks each database. */ + readonly trackSwitch?: boolean; readonly localVersion: string | null; readonly phase: "providers" | "servers" | "countdown" | "installing"; readonly restartAt: number | null; @@ -265,7 +472,10 @@ export function describeUpdateCapsule( return { tone: "failed", label: failed.length === 1 ? `${failed[0]!.machine.label} failed` : `${failed.length} failed`, - action: failed.some(({ machine }) => machine.server !== null) ? "retry" : "dismiss", + action: + !run.trackSwitch && failed.some(({ machine }) => machine.server !== null) + ? "retry" + : "dismiss", }; } return { tone: "default", label: `${done} of ${machines.length} updated`, action: null }; diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index af38bbf5d4bc4..d8ab3a5bce710 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -13,7 +13,9 @@ import { getProviderUpdateRunOutcome } from "../ProviderUpdateLaunchNotification import { useProviderUpdates } from "../ProviderUpdatesAction"; import { canUpdateServer, type ServerUpdateOutcome, useServerUpdate } from "../ServerUpdateAction"; import { + type ChannelSwitchRelease, closeFinishedUpdateRun, + collectChannelSwitchTargets, collectRestartingMachines, collectServerUpdateTargets, collectUpdateEverythingResult, @@ -34,9 +36,24 @@ import { const UPDATE_RESTART_DELAY_MS = 5_000; +/** Servers this desktop app runs; they update with it. */ +export function isHostedByThisApp( + environment: ReturnType["environments"][number], +) { + return ( + isElectron && + (environment.entry.target._tag === "PrimaryConnectionTarget" || + isDesktopLocalConnectionTarget(environment.entry.target)) + ); +} + export type ReportLocalUpdateError = (title: string, description: string) => void; -export function useUpdateEverything(localUpdateVersion: string | null) { +/** `channelSwitch` makes the run move the fleet to that release's track instead of updating it. */ +export function useUpdateEverything( + localUpdateVersion: string | null, + channelSwitch?: ChannelSwitchRelease, +) { const targetVersion = localUpdateVersion ?? APP_VERSION; const { environments } = useEnvironments(); const environmentIds = environmentIdsKey(environments.map(({ environmentId }) => environmentId)); @@ -47,23 +64,22 @@ export function useUpdateEverything(localUpdateVersion: string | null) { label: environment.label, serverConfig: environment.serverConfig, connected: environment.connection.phase === "connected", - hostedByThisApp: - isElectron && - (environment.entry.target._tag === "PrimaryConnectionTarget" || - isDesktopLocalConnectionTarget(environment.entry.target)), + hostedByThisApp: isHostedByThisApp(environment), canMaintain: canUpdateServer(sessionStates[index]!), updateState: updateStates[index]!, })); - const { targets: behindServers, skipped: unreachable } = collectServerUpdateTargets( - machines, - targetVersion, - ); - // Desktop-managed servers use their app feed; only npm installs need to wait for publication. - const needsNpm = behindServers.some((server) => server.selfUpdate !== "desktop-managed"); - const published = useNpmReleasePublished(needsNpm ? targetVersion : null); + // A track switch's target came from npm already. + const { + targets: behindServers, + skipped: unreachable, + waitForNpm, + } = channelSwitch + ? { ...collectChannelSwitchTargets(machines, channelSwitch), waitForNpm: [] } + : collectServerUpdateTargets(machines, targetVersion, localUpdateVersion !== null); + const published = useNpmReleasePublished(waitForNpm.length > 0 ? targetVersion : null); const servers = published ? behindServers - : behindServers.filter((server) => server.selfUpdate === "desktop-managed"); + : behindServers.filter((server) => !waitForNpm.includes(server)); const skipped: ReadonlyArray = [ ...unreachable, ...behindServers @@ -74,14 +90,18 @@ export function useUpdateEverything(localUpdateVersion: string | null) { reason: `t3@${targetVersion} is not on npm yet`, })), ]; - const restarts = collectRestartingMachines(machines, servers, localUpdateVersion !== null); - const providers = useProviderUpdates(); + // Track-only hosts save a setting and restart nothing, so they are neither listed nor counted. + const moving = servers.filter((server) => server.trackOnly !== true); + const restarts = collectRestartingMachines(machines, moving, localUpdateVersion !== null); + const providerUpdates = useProviderUpdates(); + // A track switch moves app and servers only; provider updates stay a separate choice. + const providers = channelSwitch ? { machines: [], updateAll: async () => [] } : providerUpdates; const updateServer = useServerUpdate(); const view = describeUpdateEverything({ localVersion: localUpdateVersion, localMachines: machines.filter((machine) => machine.hostedByThisApp), - servers, + servers: moving, skipped, providerMachines: providers.machines.map((machine) => ({ environmentId: machine.environmentId, @@ -138,6 +158,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) { } const localVersion = installLocal ? localUpdateVersion : null; setUpdateEverythingRun(() => ({ + ...(channelSwitch ? { trackSwitch: true } : {}), localVersion, phase: "providers", restartAt: null, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 039c684dcdc36..bd9dfdcdcc4b6 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -65,10 +65,13 @@ describe("buildThreadActionMenuItems", () => { }, ); - it("disables archive while the thread is running", () => { - const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( - (item) => item.id === "archive", - ); - expect(archiveItem?.disabled).toBe(true); + it("disables archive and turning into a project while the thread is running", () => { + const items = buildThreadActionMenuItems({ + ...baseState, + isRunning: true, + canTurnIntoProject: true, + }); + expect(items.find((item) => item.id === "archive")?.disabled).toBe(true); + expect(items.find((item) => item.id === "turn-into-project")?.disabled).toBe(true); }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 7779a9db4c3ed..3c4626c1ea1ad 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -4,6 +4,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" export type ThreadActionMenuId = | "new-thread-on-branch" | "filter-by-project" + | "turn-into-project" | "project-settings" | "pin" | "unpin" @@ -90,6 +91,8 @@ export interface ThreadActionMenuState { readonly snoozePresets: ReadonlyArray; /** Guest links need their own grant, so the caller checks it instead of `canOperate`. */ readonly canShareWithGuest?: boolean; + /** A No project thread with its own folder, on a server that can promote it. */ + readonly canTurnIntoProject?: boolean; } /** Local navigation, read markers, and copying remain available to read-only clients. */ @@ -183,6 +186,16 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(state.canTurnIntoProject + ? [ + { + id: "turn-into-project" as const, + label: "Turn into project", + icon: "folder-plus", + disabled: state.isRunning, + }, + ] + : []), ...(state.supports.autoSettleOptOut ? [ { diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 287c871beb8b0..95c459b8f8e8f 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -163,7 +163,7 @@ function MenuCheckboxItem({ variant = "default", ...props }: MenuPrimitive.CheckboxItem.Props & { - variant?: "default" | "switch"; + variant?: "default" | "icon" | "switch"; }) { return ( ) : ( <> - + { expect(applyEdit("- [ ] ", listContinuationForEnter("- [ ] ", 6)!)).toBe(""); }); + it("lifts an empty nested item one level per press before leaving the list", () => { + let value = "1. parent\n 1. child\n 1. "; + for (const expected of [ + "1. parent\n 1. child\n 2. ", + "1. parent\n 1. child\n2. ", + "1. parent\n 1. child\n", + ]) { + value = applyEdit(value, listContinuationForEnter(value, value.length)!); + expect(value).toBe(expected); + } + }); + + it("renumbers the parent list after lifting an empty nested item", () => { + const value = "1. a\n 1. \n2. b"; + const edit = listContinuationForEnter(value, value.indexOf("\n2."))!; + expect(applyEdit(value, edit)).toBe("1. a\n2. \n3. b"); + expect(edit.cursorAfter).toBe("1. a\n2. ".length); + }); + it("refuses to split a supplementary currency skill chip", () => { const value = "1. 𑿝review go"; const cursor = value.indexOf(" go") - 1; @@ -44,6 +67,27 @@ describe("composer list continuation", () => { }); }); +describe("composer list indentation", () => { + // regression: #17124 Tab must nest a numbered item at its parent's content column + it.each([ + ["1. parent\n2. child", "1. parent\n 1. child"], + ["10. parent\n11. child", "10. parent\n 1. child"], + ["1.\tparent\n2. child", "1.\tparent\n 1. child"], + ['1. @"file\n 9. name"\n2. child', '1. @"file\n 9. name"\n 1. child'], + ])("nests %j as a first child and keeps the caret on its text", (value, expected) => { + const cursor = value.lastIndexOf("child") + 2; + const edit = listIndentForTab(value, cursor, cursor)!; + expect(applyEdit(value, edit)).toBe(expected); + expect(edit.cursorAfter).toBe(expected.lastIndexOf("child") + 2); + }); + + it("continues the numbering of an existing nested sibling", () => { + const value = "1. parent\n 1. first\n2. second"; + const edit = listIndentForTab(value, value.length, value.length)!; + expect(applyEdit(value, edit)).toBe("1. parent\n 1. first\n 2. second"); + }); +}); + describe("nextOrderedMarkerText", () => { it.each([ ["1.", "2."], diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 1c2de5a482433..583cf1bd04a92 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -13,39 +13,60 @@ type ListMarker = | { kind: "task"; indent: string; space: string } | { kind: "bullet"; indent: string; bullet: string; space: string }; -function parseListMarker(line: string): { marker: ListMarker; markerEnd: number } | null { +interface ParsedListMarker { + marker: ListMarker; + markerEnd: number; + /** Column a nested item must reach to belong to this item. */ + contentColumn: number; +} + +function parseListMarker(line: string): ParsedListMarker | null { const indent = line.match(/^[ \t]*/)?.[0] ?? ""; const rest = line.slice(indent.length); const ordered = rest.match(/^(\d+)([.)])((?:[ \t]+)|\s*$)/); if (ordered && ordered[1] !== undefined && ordered[2] !== undefined) { + const space = ordered[3] ?? ""; return { marker: { kind: "ordered", indent, numberText: ordered[1], delimiter: ordered[2] === ")" ? ")" : ".", - space: ordered[3] ?? "", + space, }, markerEnd: indent.length + ordered[0].length, + contentColumn: contentColumn(indent, ordered[1].length + 1, space), }; } - const task = rest.match(/^-\s\[[ xX]\]((?:[ \t]+)|\s*$)/); - if (task) { + // The checkbox is item content, so a task's content starts after "- ". + const task = rest.match(/^-(\s)\[[ xX]\]((?:[ \t]+)|\s*$)/); + if (task && task[1] !== undefined) { return { - marker: { kind: "task", indent, space: task[1] ?? "" }, + marker: { kind: "task", indent, space: task[2] ?? "" }, markerEnd: indent.length + task[0].length, + contentColumn: contentColumn(indent, 1, task[1]), }; } const bullet = rest.match(/^([-*+])((?:[ \t]+)|\s*$)/); if (bullet && bullet[1] !== undefined) { + const space = bullet[2] ?? ""; return { - marker: { kind: "bullet", indent, bullet: bullet[1], space: bullet[2] ?? "" }, + marker: { kind: "bullet", indent, bullet: bullet[1], space }, markerEnd: indent.length + bullet[0].length, + contentColumn: contentColumn(indent, 1, space), }; } return null; } +// CommonMark content follows 1-4 columns of padding; wider padding is code, so the content +// column sits one column past the marker. +function contentColumn(indent: string, markerWidth: number, padding: string): number { + const markerEnd = indentWidth(indent) + markerWidth; + const space = indentWidth(padding, markerEnd); + return markerEnd + (space >= 1 && space <= 4 ? space : 1); +} + export function nextOrderedMarkerText(marker: string): string { const numberText = marker.slice(0, -1); const delimiter = marker.slice(-1); @@ -67,14 +88,14 @@ function nextMarkerText(marker: ListMarker): string { } // Markdown tabs advance to the next four-column stop. -function indentWidth(line: string): number { - let width = 0; +function indentWidth(line: string, fromColumn = 0): number { + let column = fromColumn; for (const char of line) { - if (char === " ") width += 1; - else if (char === "\t") width += 4 - (width % 4); + if (char === " ") column += 1; + else if (char === "\t") column += 4 - (column % 4); else break; } - return width; + return column - fromColumn; } function renumberFollowingItems( @@ -149,6 +170,55 @@ function currentLine(value: string, cursor: number): { start: number; end: numbe return { start, end, text: value.slice(start, end) }; } +function* listMarkersAbove( + value: string, + lineStart: number, + tokens: ReturnType, +): Generator { + let tokenIndex = tokens.length - 1; + while (lineStart > 0) { + const lineEnd = lineStart - 1; + lineStart = value.lastIndexOf("\n", lineEnd - 1) + 1; + // A newline inside a chip does not start a list item. + for (let token = tokens[tokenIndex]; token; token = tokens[tokenIndex]) { + if (token.start >= lineStart) tokenIndex -= 1; + else if (token.end > lineStart) lineStart = value.lastIndexOf("\n", token.start - 1) + 1; + else break; + } + const parsed = parseListMarker(value.slice(lineStart, lineEnd)); + if (!parsed) return; + yield parsed; + } +} + +function parentMarker( + value: string, + lineStart: number, + marker: ListMarker, + tokens: ReturnType, +): ListMarker | null { + const width = indentWidth(marker.indent); + for (const above of listMarkersAbove(value, lineStart, tokens)) { + if (indentWidth(above.marker.indent) < width) return above.marker; + } + return null; +} + +function nestedNumber( + marker: ListMarker, + previousChild: ListMarker | undefined, +): { replacedLength: number; text: string } { + if (marker.kind !== "ordered") return { replacedLength: 0, text: "" }; + const continues = + previousChild?.kind === "ordered" && previousChild.delimiter === marker.delimiter; + return { + replacedLength: marker.numberText.length, + text: continues + ? nextOrderedMarkerText(`${previousChild.numberText}${marker.delimiter}`).slice(0, -1) + : "1", + }; +} + export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null { if (!Number.isInteger(cursor) || cursor < 0 || cursor > value.length) return null; const line = currentLine(value, cursor); @@ -159,12 +229,21 @@ export function listContinuationForEnter(value: string, cursor: number): Compose const tokens = inlineTokenRanges(value); if (isInsideInlineToken(tokens, cursor)) return null; if (value.slice(markerEnd, line.end).trim() === "") { - return { - start: line.start, - end: Math.max(cursor, markerEnd), - replacement: "", - cursorAfter: line.start, - }; + const parent = parentMarker(value, line.start, parsed.marker, tokens); + const replacement = parent ? nextMarkerText(parent) : ""; + const cursorAfter = line.start + replacement.length; + const end = Math.max(cursor, markerEnd); + const renumbered = + parent?.kind === "ordered" ? renumberFollowingItems(value, line.end, parent, tokens) : null; + if (renumbered) { + return { + start: line.start, + end: renumbered.end, + replacement: replacement + value.slice(end, line.end) + renumbered.text, + cursorAfter, + }; + } + return { start: line.start, end, replacement, cursorAfter }; } const insertion = `\n${nextMarkerText(parsed.marker)}`; const cursorAfter = cursor + insertion.length; @@ -190,7 +269,36 @@ export function listIndentForTab( ): ComposerListEdit | null { if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null; const line = currentLine(value, start); - if (!parseListMarker(line.text)) return null; - if (isInsideInlineToken(inlineTokenRanges(value), start)) return null; + const parsed = parseListMarker(line.text); + if (!parsed) return null; + const tokens = inlineTokenRanges(value); + if (isInsideInlineToken(tokens, start)) return null; + const { marker } = parsed; + const width = indentWidth(marker.indent); + let previousChild: ListMarker | undefined; + for (const above of listMarkersAbove(value, line.start, tokens)) { + const aboveWidth = indentWidth(above.marker.indent); + if (aboveWidth < width) break; + if (aboveWidth > width) { + if (!previousChild || aboveWidth < indentWidth(previousChild.indent)) { + previousChild = above.marker; + } + continue; + } + const indent = + previousChild?.indent ?? above.marker.indent + " ".repeat(above.contentColumn - aboveWidth); + const number = nestedNumber(marker, previousChild); + const replacedEnd = line.start + marker.indent.length + number.replacedLength; + const replacement = indent + number.text; + return { + start: line.start, + end: replacedEnd, + replacement, + cursorAfter: + start < replacedEnd + ? line.start + indent.length + : start - replacedEnd + line.start + replacement.length, + }; + } return { start: line.start, end: line.start, replacement: " ", cursorAfter: start + 2 }; } diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 56e85545e6f1e..eece920cba664 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -124,6 +124,16 @@ const ICON_PATHS: Record(() => ({ notice: null })); -export function threadUndoNoticeSummary(notice: Pick): string { +function threadUndoNoticeSummary(notice: Pick): string { const noun = notice.action === "Discarded" ? "draft" : "thread"; return `${notice.action} ${notice.count} ${noun}${notice.count === 1 ? "" : "s"}`; } diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 9e28306500466..6faf5bd76d73d 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -200,6 +200,7 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ hasExplicitComposerModelSelection: () => false, resolveNewThreadModelSelectionOverride: () => null, })); +vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: () => testState.projectFileRead, })); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 235dc557bab63..e95a2d0d799bd 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -44,6 +44,8 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { getClientSettings } from "./useSettings"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; +import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -144,7 +146,26 @@ export function createNewThreadHandler(router: AnyRouter) { project?.id ?? null, project, ); - const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; + const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null; + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + configuredDefaultModelSelection ?? + (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null + ? null + : resolveProjectDefaultProviderModelSelection( + environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + targetServerSettings, + projectRef.projectId, + )), + fallbackSelection: targetServerSettings.fallbackModelSelection ?? null, + providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + settings: targetServerSettings, + projectId: projectRef.projectId, + now: Date.now(), + }); + const projectDefaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : configuredDefaultModelSelection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 4fe9ad45ad53b..e87092d59c37e 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -119,6 +119,7 @@ vi.mock("./useSettings", () => ({ }), })); vi.mock("./useThreadActions", () => ({ + canTurnThreadIntoProject: () => false, useThreadActions: () => Object.fromEntries( [ diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index b7bec9d5e988d..0e25cebeef878 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -45,7 +45,7 @@ import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; -import { useThreadActions } from "./useThreadActions"; +import { canTurnThreadIntoProject, useThreadActions } from "./useThreadActions"; function failureToast(title: string, error: unknown) { toastManager.add( @@ -84,6 +84,7 @@ export function useThreadActionMenu(input: { archiveThread, deleteThread, markThreadUnread, + turnThreadIntoProject, } = useThreadActions(); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -144,6 +145,7 @@ export function useThreadActionMenu(input: { supports, snoozePresets, canShareWithGuest, + canTurnIntoProject: canTurnThreadIntoProject(threadRef), }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -254,6 +256,19 @@ export function useThreadActionMenu(input: { case "share-guest": openGuestShareDialog(threadRef); return; + case "turn-into-project": { + const result = await turnThreadIntoProject(threadRef); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Project created", + description: thread.title, + }); + } else if (!isAtomCommandInterrupted(result)) { + failureToast("Could not turn thread into a project", squashAtomCommandFailure(result)); + } + return; + } case "copy-path": { const workspacePath = thread.worktreePath ?? projectCwd; if (!workspacePath) { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 211dc1bef67ef..32020316496ed 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -29,6 +29,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { orchestrationEnvironment } from "../state/orchestration"; +import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -228,6 +229,20 @@ export function useAcknowledgeThreadWoke() { }; } +/** A No project thread that already has its own folder, on a server that can promote it. */ +export function canTurnThreadIntoProject(threadRef: ScopedThreadRef): boolean { + const thread = readThreadShell(threadRef); + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(threadRef.environmentId); + if (!thread?.worktreePath || config?.environment.capabilities.threadProjectFromScratch !== true) { + return false; + } + const project = readProject({ + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); + return project !== null && isScratchProject(project, config.scratchWorkspaceRoot); +} + function threadOperationFailure(target: ScopedThreadRef) { return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) ? null @@ -279,6 +294,9 @@ export function useThreadActions() { reportFailure: false, }); const markThreadUnread = useMarkThreadUnread(); + const createProjectFromThread = useAtomCommand(projectEnvironment.createFromThread, { + reportFailure: false, + }); const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -925,5 +943,10 @@ export function useThreadActions() { reorderActiveThread, markThreadUnread, setThreadAutoSettle, + turnThreadIntoProject: (target: ScopedThreadRef) => + createProjectFromThread({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }), }; } diff --git a/apps/web/src/modelPickerVisibility.ts b/apps/web/src/modelPickerVisibility.ts index a8177fe83b5c6..17de95380fb87 100644 --- a/apps/web/src/modelPickerVisibility.ts +++ b/apps/web/src/modelPickerVisibility.ts @@ -1,8 +1,13 @@ -const MODEL_PICKER_CONTENT_SELECTOR = "[data-model-picker-content]"; +// Keydown handlers read this on every key, so a mount count replaces a document query. +let mountedModelPickers = 0; + +export function registerModelPickerContent(): () => void { + mountedModelPickers += 1; + return () => { + mountedModelPickers -= 1; + }; +} export function isModelPickerOpen(): boolean { - return ( - typeof document !== "undefined" && - document.querySelector(MODEL_PICKER_CONTENT_SELECTOR) !== null - ); + return mountedModelPickers > 0; } diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 69800be4b7574..1178c91043052 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -5,14 +5,10 @@ import { threadContextReference } from "./lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { - buildPendingUserInputAnswers, carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, - resolvePendingUserInputAnswer, - setPendingUserInputCustomAnswer, threadContextsReferencedByAnswer, - togglePendingUserInputOptionSelection, } from "./pendingUserInput"; describe("projectPendingUserInputAnswer", () => { @@ -68,128 +64,6 @@ const singleSelectQuestion = { multiSelect: false, } as const; -const nativeChoiceQuestion = { - id: "result", - header: "Result", - question: "Which result should be used?", - options: [ - { value: " first\t", label: "Result", description: "First result" }, - { value: "second", label: "Result", description: "Second result" }, - ], - allowCustomAnswer: false, - multiSelect: false, -} as const; - -describe("resolvePendingUserInputAnswer", () => { - it("preserves exact editor text, including a deliberately cleared answer", () => { - const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; - expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( - question.initialAnswer, - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( - " Edited message\n\n", - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); - expect( - setPendingUserInputCustomAnswer( - { selectedOptionValues: ["Orchestration-first"] }, - "", - question, - ), - ).toEqual({ customAnswer: "" }); - const drafts = { scope: { customAnswer: "" } }; - expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ - customAnswer: "", - resolvedAnswer: "", - canAdvance: true, - isComplete: true, - }); - }); - - it("prefers a custom answer over selected options", () => { - expect( - resolvePendingUserInputAnswer(singleSelectQuestion, { - selectedOptionValues: ["Orchestration-first"], - customAnswer: "Keep the existing envelope for one release", - }), - ).toBe("Keep the existing envelope for one release"); - }); - - it("does not replace a required choice with a custom answer", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["second"], - customAnswer: "Use another result", - }), - ).toBe("second"); - }); - - it("does not submit labels or unknown values when an option has a value", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["Result", "unknown"], - }), - ).toBeNull(); - }); -}); - -describe("togglePendingUserInputOptionSelection", () => { - it("selects and removes options with the same label independently", () => { - const question = { ...nativeChoiceQuestion, multiSelect: true }; - const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); - const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); - - expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ - result: [" first\t", "second"], - }); - - const secondSelected = togglePendingUserInputOptionSelection( - question, - bothSelected, - " first\t", - ); - expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ - result: ["second"], - }); - }); -}); - -describe("buildPendingUserInputAnswers", () => { - it("returns a canonical answer map for complete prompts", () => { - expect( - buildPendingUserInputAnswers( - [ - singleSelectQuestion, - { - id: "compat", - header: "Compat", - question: "How strict should compatibility be?", - options: [ - { - label: "Keep current envelope", - description: "Preserve current wire format", - }, - ], - multiSelect: false, - }, - ], - { - scope: { - selectedOptionValues: ["Orchestration-first"], - }, - compat: { - customAnswer: "Keep the current envelope for one release window", - }, - }, - ), - ).toEqual({ - scope: "Orchestration-first", - compat: "Keep the current envelope for one release window", - }); - }); -}); - describe("pending user input question progress", () => { const questions = [ singleSelectQuestion, @@ -207,6 +81,17 @@ describe("pending user input question progress", () => { }, ] as const; + it("counts a deliberately cleared answer as answered", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + const drafts = { scope: { customAnswer: "" } }; + expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ + customAnswer: "", + resolvedAnswer: "", + canAdvance: true, + isComplete: true, + }); + }); + it("derives the active question and advancement state", () => { expect( derivePendingUserInputProgress( @@ -232,25 +117,6 @@ describe("pending user input question progress", () => { }); }); -it("accepts attachment-only answers after every upload finishes", () => { - const questions = [ - { id: "spec", header: "Spec", question: "Provide a spec", options: [], multiSelect: false }, - ]; - expect(buildPendingUserInputAnswers(questions, { spec: { attachmentCount: 1 } })).toEqual({ - spec: "", - }); - expect( - buildPendingUserInputAnswers(questions, { - spec: { attachmentCount: 1, attachmentsBlocked: true }, - }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([{ ...questions[0]!, allowCustomAnswer: false }], { - spec: { attachmentCount: 1 }, - }), - ).toBeNull(); -}); - describe("carryDisplacedCustomAnswerIntoPrompt", () => { // regression: #12577 clicking a question option discarded typed text it("keeps the thread draft when nothing was typed into the answer", () => { diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index f8f9d6d52b5ba..17998eaf8010c 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,6 +1,13 @@ import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { + buildPendingUserInputAnswers, + normalizeSelectedOptionValues, + resolvePendingUserInputAnswer, + type PendingUserInputAnswer, + type PendingUserInputDraftAnswer, +} from "@t3tools/client-runtime/state/pending-user-input"; import { collectInlineContextIds } from "./lib/composerContextReferences"; @@ -25,20 +32,13 @@ export function threadContextsReferencedByAnswer( return threadContexts.filter((record) => referencedIds.has(record.contextId)); } -export interface PendingUserInputDraftAnswer { - selectedOptionValues?: string[]; - customAnswer?: string; - attachmentCount?: number; - attachmentsBlocked?: boolean; -} - export interface PendingUserInputProgress { questionIndex: number; activeQuestion: UserInputQuestion | null; activeDraft: PendingUserInputDraftAnswer | undefined; - selectedOptionValues: string[]; + selectedOptionValues: ReadonlyArray; customAnswer: string; - resolvedAnswer: string | string[] | null; + resolvedAnswer: PendingUserInputAnswer | null; usingCustomAnswer: boolean; answeredQuestionCount: number; isLastQuestion: boolean; @@ -46,72 +46,6 @@ export interface PendingUserInputProgress { canAdvance: boolean; } -function normalizeDraftAnswer(value: string | undefined): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeSelectedOptionValues(value: string[] | undefined): string[] { - if (!Array.isArray(value)) { - return []; - } - - // Provider option IDs must stay unchanged, including whitespace. - return Array.from(new Set(value.filter((entry) => typeof entry === "string"))); -} - -export function resolvePendingUserInputAnswer( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, -): string | string[] | null { - if (draft?.attachmentsBlocked) return null; - const customAnswer = - question.allowCustomAnswer === false - ? null - : question.initialAnswer !== undefined - ? (draft?.customAnswer ?? null) - : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer !== null && customAnswer.length > 0) { - return customAnswer; - } - - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter( - (value) => question.options.some((option) => (option.value ?? option.label) === value), - ); - if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : (customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); - } - - return ( - selectedOptionValues[0] ?? - customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) - ); -} - -export function setPendingUserInputCustomAnswer( - draft: PendingUserInputDraftAnswer | undefined, - customAnswer: string, - question?: Pick, -): PendingUserInputDraftAnswer { - const selectedOptionValues = - question?.initialAnswer !== undefined || customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionValues(draft?.selectedOptionValues); - - return { - customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; -} - const DISPLACED_ANSWER_SEPARATOR = "\n\n"; export function carryDisplacedCustomAnswerIntoPrompt( @@ -128,48 +62,6 @@ export function carryDisplacedCustomAnswerIntoPrompt( return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; } -export function togglePendingUserInputOptionSelection( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): PendingUserInputDraftAnswer { - if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); - const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) - ? selectedOptionValues.filter((value) => value !== optionValue) - : [...selectedOptionValues, optionValue]; - - return { - customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } - : {}), - }; - } - - return { - customAnswer: "", - selectedOptionValues: [optionValue], - }; -} - -export function buildPendingUserInputAnswers( - questions: ReadonlyArray, - draftAnswers: Record, -): Record | null { - const answers: Record = {}; - - for (const question of questions) { - const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { - return null; - } - answers[question.id] = answer; - } - - return answers; -} - function countAnsweredPendingUserInputQuestions( questions: ReadonlyArray, draftAnswers: Record, @@ -203,7 +95,9 @@ export function derivePendingUserInputProgress( questionIndex: normalizedQuestionIndex, activeQuestion, activeDraft, - selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), + selectedOptionValues: activeQuestion + ? normalizeSelectedOptionValues(activeQuestion, activeDraft?.selectedOptionValues) + : [], customAnswer, resolvedAnswer, usingCustomAnswer: diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index a3364df9a15d6..f9e711c40e7fc 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -240,3 +240,19 @@ export function resolveDefaultProviderModelSelection( const model = getDefaultProviderInstanceModel(providers, instanceId); return model ? { instanceId, model } : null; } + +export function resolveProjectDefaultProviderModelSelection( + providers: ReadonlyArray, + settings: ServerSettings, + projectId: ProjectId | null, +): ModelSelection | null { + const entries = providerInstanceEntriesFor(providers, settings, projectId); + // Matches resolveComposerProviderSelection: the first driver group wins before any other. + const entry = + resolveSelectableProviderInstanceEntry( + entries.filter((candidate) => candidate.driverKind === entries[0]?.driverKind), + undefined, + ) ?? resolveSelectableProviderInstanceEntry(entries, undefined); + const model = entry ? getDefaultProviderInstanceModel(providers, entry.instanceId) : undefined; + return entry && model ? { instanceId: entry.instanceId, model } : null; +} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f905376dc341a..9c4e38c30e510 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1472,6 +1472,51 @@ describe("incremental v2 timeline entries", () => { expect(next.entries[index]).toBe(entry); }); + it("re-derives only the work entry whose tool item changed", () => { + const fixture = makeStreamingTimelineFixture("Partial"); + const visibleTurnItems = fixture.visibleTurnItems.map((row) => + row.item.id === "live-work" + ? { ...row, item: { ...row.item, status: "running" as const, completedAt: null } } + : row, + ); + const input = { visibleTurnItems, optimisticMessages: [] }; + const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); + const nextInput = { ...input, visibleTurnItems: fixture.visibleTurnItems }; + const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); + expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); + for (const [index, entry] of previous.entries.entries()) { + if (entry.id === "live-work") expect(next.entries[index]).not.toBe(entry); + else expect(next.entries[index]).toBe(entry); + } + + const anchoredInput = { + ...nextInput, + anchoredMessages: [ + { + id: MessageId.make("feedback"), + runId: null, + role: "assistant" as const, + text: "Feedback received", + streaming: false, + createdAt: fixture.time(6), + updatedAt: fixture.time(6), + }, + ], + }; + const anchored = deriveTimelineEntriesFromVisibleTurnItemsWithState(anchoredInput); + const movedInput = { + ...anchoredInput, + visibleTurnItems: anchoredInput.visibleTurnItems.map((row) => + row.item.id === "live-work" + ? { ...row, item: { ...row.item, startedAt: DateTime.makeUnsafe(fixture.time(8)) } } + : row, + ), + }; + expect( + deriveTimelineEntriesFromVisibleTurnItemsWithState(movedInput, anchored).entries, + ).toEqual(deriveTimelineEntriesFromVisibleTurnItems(movedInput)); + }); + it.each(["completion", "provenance", "ordering", "attachment", "run"] as const)( "rebuilds entries for a %s change instead of retaining stale v2 metadata", (change) => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 37d7ad27be5a7..7b79dea6f7401 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -903,6 +903,27 @@ export function isStreamingTurnItemTextUpdate( return Equal.equals(previousMetadata, nextMetadata); } +/** + * A changed tool or work item maps to exactly one entry with the item's id. + * Messages and question answers take part in cross-entry dedupe, and anchored + * messages are placed by createdAt, so those changes rebuild. + */ +function canRederiveInPlace( + input: TimelineEntriesInput, + previousItem: OrchestrationV2ProjectedTurnItem, + item: OrchestrationV2ProjectedTurnItem, +): boolean { + return ( + item.item.id === previousItem.item.id && + item.item.type === previousItem.item.type && + item.item.type !== "user_message" && + item.item.type !== "assistant_message" && + item.item.type !== "user_input_request" && + ((input.anchoredMessages?.length ?? 0) === 0 || + projectedItemCreatedAt(item) === projectedItemCreatedAt(previousItem)) + ); +} + function reuseTimelineEntries( input: TimelineEntriesInput, previous: TimelineEntriesProjection, @@ -943,15 +964,45 @@ function reuseTimelineEntries( OrchestrationV2ProjectedTurnItem, OrchestrationV2ProjectedTurnItem >(); + const changedItems: OrchestrationV2ProjectedTurnItem[] = []; for (let index = 0; index < before.visibleTurnItems.length; index += 1) { const previousItem = before.visibleTurnItems[index]!; const item = input.visibleTurnItems[index]!; if (item === previousItem) continue; - if (!isStreamingTurnItemTextUpdate(previousItem, item)) return null; - replacements.set(previousItem, item); + if (isStreamingTurnItemTextUpdate(previousItem, item)) { + replacements.set(previousItem, item); + continue; + } + if (!canRederiveInPlace(input, previousItem, item)) return null; + changedItems.push(item); } - if (replacements.size === 0 && !appended) return previous.entries; + // One batch keeps the attempt/node/plan lookups to a single build. + const rederived = + changedItems.length === 0 + ? [] + : deriveTimelineEntriesFromVisibleTurnItems({ + ...input, + visibleTurnItems: changedItems, + optimisticMessages: [], + anchoredMessages: [], + }); + if ( + rederived.length !== changedItems.length || + rederived.some((entry, index) => entry.id !== changedItems[index]!.item.id) + ) { + return null; + } + const rederivedEntries = new Map(rederived.map((entry) => [entry.id, entry])); + if (replacements.size === 0 && rederivedEntries.size === 0 && !appended) { + return previous.entries; + } + let rederivedCount = 0; const entries = previous.entries.map((entry): TimelineEntry => { + const rederivedEntry = rederivedEntries.get(entry.id); + if (rederivedEntry !== undefined) { + rederivedCount += 1; + return rederivedEntry; + } const row = entry.kind === "message" && entry.projectedItem !== undefined ? replacements.get(entry.projectedItem) @@ -967,6 +1018,7 @@ function reuseTimelineEntries( }, }; }); + if (rederivedCount !== rederivedEntries.size) return null; if (appended) { entries.push( ...deriveTimelineEntriesFromVisibleTurnItems({ diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts index fcd4bb4b8f2ec..da19cc7d5a2b9 100644 --- a/apps/web/src/state/device.ts +++ b/apps/web/src/state/device.ts @@ -171,3 +171,14 @@ export const deviceEasAccountCheck = createEnvironmentRpcQueryAtomFamily(connect staleTimeMs: 0, idleTtlMs: 0, }); + +/** Who a Simlock token is, checked as the host editor's URL and token fields change. */ +export const deviceSimlockIdentityCheck = createEnvironmentRpcQueryAtomFamily( + connectionAtomRuntime, + { + label: "environment-data:device:simlock-identity-check", + tag: WS_METHODS.deviceSimlockIdentity, + staleTimeMs: 0, + idleTtlMs: 0, + }, +); diff --git a/apps/web/src/state/npmRelease.ts b/apps/web/src/state/npmRelease.ts index 4436a42c29e4b..09db3b68c0691 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -1,31 +1,51 @@ import { useAtomValue } from "@effect/atom-react"; +import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; import * as Effect from "effect/Effect"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import { AsyncResult, Atom } from "effect/reactivity"; -export const npmReleasePublishedAtom = Atom.family((version: string) => +export interface NpmRelease { + readonly version: string; + /** Absent on releases published before npm manifests carried it. */ + readonly databaseMigration: number | undefined; +} + +/** `spec` is an exact version or a dist-tag such as `nightly`. Null when npm has no such release. + * Refreshes only while mounted; the published check below keeps its own versions alive. */ +export const npmReleaseAtom = Atom.family((spec: string) => Atom.make( Effect.gen(function* () { const response = yield* HttpClient.get( - `https://registry.npmjs.org/t3/${encodeURIComponent(version)}`, + `https://registry.npmjs.org/t3/${encodeURIComponent(spec)}`, ); - if (response.status === 404) return false; + if (response.status === 404) return null; const body = yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.flatMap((ok) => ok.json), ); - return typeof body === "object" && body !== null && "version" in body - ? body.version === version - : false; + if ( + typeof body !== "object" || + body === null || + !("version" in body) || + typeof body.version !== "string" + ) { + return null; + } + return { + version: body.version, + databaseMigration: npmReleaseDatabaseMigration(body), + } satisfies NpmRelease; }).pipe( // Tracing headers would make the browser send a CORS preflight, which npm rejects. Effect.provideService(HttpClient.TracerPropagationEnabled, false), Effect.provide(FetchHttpClient.layer), ), - ).pipe( - Atom.withRefresh("5 minutes"), - Atom.keepAlive, - Atom.withLabel(`npm-release-published:${version}`), - ), + ).pipe(Atom.withRefresh("5 minutes"), Atom.withLabel(`npm-release:${spec}`)), +); + +export const npmReleasePublishedAtom = Atom.family((version: string) => + Atom.make((get) => + get.result(npmReleaseAtom(version)).pipe(Effect.map((release) => release?.version === version)), + ).pipe(Atom.keepAlive, Atom.withLabel(`npm-release-published:${version}`)), ); const NO_VERSION_ATOM = Atom.make(AsyncResult.success(false)).pipe( 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/internals/remote.md b/docs/internals/remote.md index d88d2973f4e44..0c328c50317c5 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -73,7 +73,7 @@ A [peer link](../../apps/server/src/peer/PeerLinks.ts) lets one environment's agents work in another. The linking environment signs in to the other's `/mcp` as an outside MCP client, with a pairing code from it, and keeps that session's token in its own secret store. The other environment needs nothing new: it -sees one more OAuth client, lists it in Connections, and revokes it there. +sees one more OAuth client, lists it in Sharing & access, and revokes it there. Unlinking on the linking side only forgets the token. - **The Linked environments beta (`linkedEnvironmentsEnabled`) gates both diff --git a/docs/operations/development.md b/docs/operations/development.md index 211041c22a24e..3307dbc22b969 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -163,7 +163,9 @@ for the security model. `vp run check:changed` runs CI's fast checks for your branch's changes: format and lint of the changed files, `knip:check` when code or a manifest changes, the generated GraphQL checks, typecheck -of the affected packages and their dependents, and the test files beside the changed files. It maps +of the affected packages and their dependents, and the test files beside the changed files. It also +runs the checks that read other files: the agent context budget when the server is affected, and +React Compiler coverage for only the changed web and mobile files. It maps files to packages with [ci-changes.cjs](../../.github/scripts/ci-changes.cjs), as CI does. It compares against the merge base with `origin/main`, or with a ref you pass. CI still runs the full test suites and the jobs the summary lists, so a local pass does not promise a green CI run. diff --git a/docs/operations/release.md b/docs/operations/release.md index 39f4f63ddd91b..18daadc5f2787 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -42,6 +42,7 @@ the version. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. + - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
    `. It needs the `OPENAI_API_KEY` secret. The summary leaves out commits whose scopes are all mobile; the header counts every commit. The desktop update popover, on every channel, shows the highlights paragraph and the header counts as a short note per release, with the offered release's age from the update feed's `releaseDate`. The nightly Discord post shows the part above `
    `. If the step fails or there is nothing to summarize, it writes no file and the release uses GitHub's generated notes as before. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. @@ -430,7 +431,11 @@ release archives, and then its npm launcher, available before users can receive The workflow enforces this ordering: -1. `release` publishes the GitHub Release with the CLI archives and `SHA256SUMS`. +1. `release` publishes the GitHub Release with the CLI archives and `SHA256SUMS`. Every channel + uploads to a draft and publishes only after all assets are attached, so a new version in the + desktop update feed already has its archives. **Update everything** relies on this to update + background-service hosts before npm has the version. Rerunning `release` after publication + replaces assets in a public release, so service updates can fail until the upload finishes. 2. `publish_cli` publishes the `t3@` launcher, which downloads from that release, on every channel. 3. `deploy_web` depends on both before moving the hosted channel to the new client. diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index befaca4c077c8..6bd4dea35d7c5 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -398,7 +398,10 @@ delegated to a linked environment name that environment. Clients open only that environment's thread and show its current title; a link to a thread they do not know renders as a label that opens nothing. Older `t3-thread://v1/` links name no environment: clients resolve them in the message's environment, -then by a unique match in another enabled environment. +then by a unique match in another enabled environment. An outside OAuth client, +one that is not a linked environment, cannot render `t3-thread://` links, so it +gets a web URL on the origin it reached, `//`, +for threads in this environment. List and read results report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the diff --git a/docs/user/composer.md b/docs/user/composer.md index e0d1f541851f0..714572dfd5005 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -22,7 +22,8 @@ block. What you typed is what the agent receives, markers and numbering included, and `#1234` without a space still looks up a pull request. Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter -on an empty line leaves it; **Tab** nests a list item. In a code block, Enter +on an empty line moves out one level, then leaves it; **Tab** nests a list item +under the one above. In a code block, Enter starts a new line at the current indentation, **Tab** and **Shift+Tab** indent the selected lines, and a closing ` ``` ` followed by Enter, or two blank lines at the end, leave the block. **Backspace** at the start of a code block turns it 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/docs/user/faq.md b/docs/user/faq.md index bb4713b5f25e8..62a5e1a8446d1 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -45,7 +45,7 @@ Get the [iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819 ## Why does T3 Connect keep disconnecting? -T3 Connect is our built-in alternative to Tailscale for remote connections. If it keeps dropping, open **Settings > Connections**, find the machine, and select **Details**. The first part marked as failed is the cause. I will not be disappointed if you set up Tailscale as a fallback (I do this personally). [T3 Connect troubleshooting](./t3-connect-troubleshooting.md) +T3 Connect is our built-in alternative to Tailscale for remote connections. If it keeps dropping, open **Settings > Environments**, find the machine, and select **Details**. The first part marked as failed is the cause. I will not be disappointed if you set up Tailscale as a fallback (I do this personally). [T3 Connect troubleshooting](./t3-connect-troubleshooting.md) ## Will I get banned for using my subscription? diff --git a/docs/user/install.md b/docs/user/install.md index 3edf46d80ee39..1e1693d0b32ee 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -65,18 +65,41 @@ update it with `git pull` and a rebuild. Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or use a package manager: -| Platform | Install | -| ------------------ | ---------------------------------- | -| Windows | `winget install T3Tools.T3Code` | -| macOS | `brew install --cask t3-code` | -| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | -| Arch Linux | `yay -S t3code-bin` | -| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Platform | Install | +| ------------------ | ------------------------------------------------------------ | +| Windows | `winget install T3Tools.T3Code` | +| macOS | `brew install --cask t3-code` | +| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | +| Arch Linux | `yay -S t3code-bin` | +| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Nix | `nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code` | The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +### Nix (nixpkgs) + +T3 Code is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code). +The command above installs from `nixos-unstable` and requires Nix 2.30 or later with +`nix-command` and `flakes` enabled. Launch the desktop app with `t3code-desktop`; +the package also includes the `t3` command. + +On NixOS, you can instead add the package to your system configuration, using +a nixpkgs revision that includes `t3code`: + +```nix +environment.systemPackages = [ pkgs.t3code ]; +``` + +Apply the change with `sudo nixos-rebuild switch`. For Home Manager, add +`pkgs.t3code` to `home.packages` and run `home-manager switch`. + +Update a profile installation with `nix profile upgrade t3code`. For NixOS or +Home Manager, update your configuration's nixpkgs input or channel and rebuild. +Nix manages both the desktop app and the bundled +`t3` command; use Nix to update them instead of the in-app updater or `t3 update`. + ### The `t3` command The desktop app includes the `t3` command-line tool. To run it from any diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 0b1cd2f2c6da1..76bbc8266a941 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -68,9 +68,15 @@ Leave the field blank to use the managed runtime, or a compatible executable on ## Models and threads -The model list comes from your Antigravity account and can differ from other -Antigravity apps. A resumed thread keeps its selected model. If access to that -model ends, select another available model before continuing. +The model list comes from your Antigravity account, including Claude and GPT models +when your plan has them. Pick a thinking level in the composer's effort menu; it +shows only the levels the selected model supports. A resumed thread keeps its +selected model. If access to that model ends, select another available model +before continuing. + +Antigravity creates empty `.gemini/skills` and `.agents/skills` folders in the +workspace when a session starts. Git does not track empty folders, so they never +appear in commits. Use Antigravity's native `/plan` command for planning. T3 Code's separate Plan mode is unavailable. Tool approvals follow [Permission modes](./permission-modes.md). diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b8f9553d95beb..eca8907e87e14 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -7,7 +7,7 @@ machine. That machine must stay running and reachable while you work. T3 Connect makes an environment available to your other devices without setting up router forwarding. In the desktop app on the host, open **Settings → -Connections**, sign in, and enable **T3 Connect** for that environment. +Sharing & access**, sign in, and enable **T3 Connect** for that environment. For a command-line host, run: @@ -280,12 +280,12 @@ off on either machine, agents start nothing over a link between them. A task already running on the other machine still finishes, and if that machine has it off, its result arrives once it is back on. -On the machine you will work from, open **Settings → Connections → Linked environments**, +On the machine you will work from, open **Settings → Environments → Linked environments**, choose **Link**, and pick the other machine or enter its address. The list holds the machines this app knows and those on your T3 Connect account. If this app is connected to that machine with a session that manages access there, it makes the pairing code itself; otherwise paste one from that machine's -**Settings → Connections**. From a terminal, use +**Settings → Sharing & access**. From a terminal, use `t3 environment link
    --pairing-code
    --access `. You can also ask an agent to link a machine by name, such as "delegate this to @@ -299,7 +299,7 @@ and the access, then approve. access, the link's agents can change only threads it started, not that machine's own threads, projects or settings, and they skip its project setup scripts. -- The link shows up in that machine's **Settings → Connections** like any +- The link shows up in that machine's **Settings → Sharing & access** like any outside agent. Revoke it there to end it. **Forget** on this machine only removes it here. - Links last 30 days. Link again with a new pairing code to renew one. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ce16b9df0374b..fb5e12b08372b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -37,6 +37,12 @@ folder, so the files the agent wrote stay until you delete them. Branch, worktre these folders are not Git repositories. This is unavailable when the data directory itself sits inside a Git checkout. +When the work grows into something real, pick **Turn into project** from the +thread's menu. The thread's folder becomes a project where it is, so nothing +moves and the agent keeps working in the same place. Other threads in that folder +move with it. Rename the project in its settings, and use **Initialize Git** to +get branch and diff controls. + ### Start in the background In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` @@ -271,6 +277,11 @@ Select a server to open it in the browser panel. **Stop** ends the server's job and keeps the agent, the terminal, and T3 Code running. Stop needs terminal access to the environment and is not available on Windows. +To choose what the thread details card shows, right-click the card or open +**Settings → Appearance → Thread details**. Hidden rows keep their keyboard +shortcuts, such as Open in editor and project scripts. **Reset to default** brings +back the standard rows. + ## Snooze until later To change the choices in the snooze menu, open **Settings → Threads → Snooze options**. diff --git a/docs/user/updating.md b/docs/user/updating.md index ad5d93b5759a2..cfe746c187338 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -22,10 +22,11 @@ restart from the update button when you are ready. A server still restarting finishes on its own, and the button keeps showing it after the desktop app restarts. -Server updates appear only after that version is published to npm. Remote -desktop apps do not wait for npm. The confirmation lists each machine it skips -and why, such as a machine that is offline or needs a manual update. Use -**Settings → Environments** for those. +Remote desktop apps never wait for npm. When the desktop app offers the update, +background services update right away too. Other servers, including older +background services, update only after that version is published to npm. The +confirmation lists each machine it skips and why, such as a machine that is +offline or needs a manual update. Use **Settings → Environments** for those. If a desktop app restarts on its old version, T3 Code tells you the update did not apply. It does not install that version again on its own, even when another @@ -35,6 +36,29 @@ The confirmation counts the running threads that the restarts will interrupt, and how many can resume afterwards. See **Continue threads after restarts** below. +## Switch between Stable and Nightly + +In the desktop app, change **Settings → About → Update track**. T3 Code shows each +connected machine it will move and each one it skips, then moves them in one +step. Remote servers install the latest release on the new track. Remote desktop +apps reinstall on the new track. This app reinstalls last. Your threads, +settings, and data stay. + +A background service cannot move to an older release remotely. When Stable is +older than its Nightly, run `t3 update --channel stable --allow-downgrade` on +that machine. + +A switch is blocked for a machine whose database already has changes the +target release does not support, or when T3 Code cannot confirm that it does. +The dialog says why: update a machine on its current track first when its +release is too old to check, or wait until the target track includes the +changes. A Mac running the desktop app must switch on that Mac. + +Each machine keeps the new track even if its switch did not finish, but it +moves to that track only through a switch. **Update track** shows how many +machines still run the other track. **Review** opens the same dialog with fresh +checks. + ## Before you update Server updates restart the connection and can interrupt active agents and diff --git a/docs/user/usage.md b/docs/user/usage.md index e41e60ca73d76..155175351c550 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -116,6 +116,12 @@ current model's limits without leaving the conversation. The result opens above closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh anything. The command is offered only for providers that appear under **Usage → Limits**. +To keep them in view on web and desktop, turn on **Usage** in **Settings → Appearance → Thread details**, or right-click the thread details card. +The bottom of the thread details card then shows how much is left in each limit window of the +current provider account and in the thread's context window. Some windows, such as a weekly Opus +limit, count only one model. Hover a row to see when it resets, your pace, or +the token count. + OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected diff --git a/infra/relay/README.md b/infra/relay/README.md index 4b5ac35e438bd..e80428074e7d0 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -1,7 +1,7 @@ # T3 Connect Relay > [!NOTE] -> Sign in to T3 Connect from the app under Settings > Connections. +> Sign in to T3 Connect from the app under Settings > Sharing & access. The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to remote environments, manages the cloud-side records needed for those connections, and delivers diff --git a/knip.jsonc b/knip.jsonc index c2093737fd942..1a3101ceda527 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -13,6 +13,8 @@ ".coderabbit.config.ts", ".github/scripts/thread-transfer-report.cjs", ], + // coil: only retired upstream workflows ran this (scripts/coil/sync-upstream.sh). + "ignore": [".github/scripts/mirror-play-apk.cjs"], "vite": { "entry": [".github/**/*.test.cjs"] }, "vitest": { "entry": [".github/**/*.test.cjs"] }, }, @@ -39,7 +41,16 @@ // The `t3` npm package's files: copied into the package, never imported. // knip-schemas.test.ts writes a throwaway project to .knip-test-*, which a // knip run beside it in check:changed must not report. - "ignore": ["npm-launcher/**", ".knip-test-*/**"], + // coil: worklog runs by path, and the rest only ran from retired upstream workflows. + "ignore": [ + "npm-launcher/**", + ".knip-test-*/**", + "coil/**", + "build-cli-archive.ts", + "notify-discord-release.ts", + "resolve-nightly-release.ts", + "smoke-cli-archive.ts", + ], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. @@ -92,6 +103,8 @@ // named subcomponents even before they have callers; the file audit still // reports an entire component module when nothing imports it. "ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] }, + // coil: only the retired release.yml ran this. + "ignore": ["scripts/verify-built-boot.ts"], }, "apps/marketing": { // `astro check` runs from the typecheck task in vite.config.ts, which knip does not read. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 66e79c7492ae3..5808641a5606a 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -331,6 +331,10 @@ "types": "./src/state/threadExecution.ts", "default": "./src/state/threadExecution.ts" }, + "./state/pending-user-input": { + "types": "./src/state/pendingUserInput.ts", + "default": "./src/state/pendingUserInput.ts" + }, "./state/thread-requests": { "types": "./src/state/threadRequests.ts", "default": "./src/state/threadRequests.ts" 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/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index 2378407c2df73..d4be3f1a9f456 100644 --- a/packages/client-runtime/src/operations/commands.performance.test.ts +++ b/packages/client-runtime/src/operations/commands.performance.test.ts @@ -223,7 +223,7 @@ describe("routine command transport budget", () => { if (command.type !== "message.dispatch") { throw new Error(`Expected message.dispatch, received ${command.type}`); } - if (!("sequence" in receipt)) { + if ("projection" in receipt) { throw new Error("Expected a dispatch receipt"); } const { deliveryIntent: _deliveryIntent, ...formerCommand } = command; diff --git a/packages/client-runtime/src/pullRequestEditing.ts b/packages/client-runtime/src/pullRequestEditing.ts index 96b4d5c494f9b..e65c19009e653 100644 --- a/packages/client-runtime/src/pullRequestEditing.ts +++ b/packages/client-runtime/src/pullRequestEditing.ts @@ -25,6 +25,41 @@ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean ); } +export function canEditPullRequestMaintainerEdits( + detail: Pick< + PullRequestDetail, + "state" | "capabilities" | "viewerPermissions" | "maintainerCanModify" + >, +): boolean { + return ( + detail.state === "open" && + detail.capabilities.edit?.maintainerEdits === true && + detail.viewerPermissions.maintainerEdits === true && + detail.maintainerCanModify !== undefined + ); +} + +export interface MaintainerEditsChoice { + readonly key: string; + readonly observedAt: number | undefined; + readonly value: boolean; +} + +/** + * A saved choice ends at the first host reading delivered after the save. Readers refresh once it + * succeeds, which drops reads still in flight, so that reading started after the write. + */ +export function maintainerEditsChoiceInForce( + choice: MaintainerEditsChoice | null, + key: string, + observedAt: number | undefined, + saving: boolean, +): MaintainerEditsChoice | null { + if (choice === null || choice.key !== key) return null; + if (saving) return choice.observedAt === observedAt ? choice : { ...choice, observedAt }; + return observedAt !== undefined && choice.observedAt === observedAt ? choice : null; +} + /** Hosts do not support rewriting review summaries through comment mutations. */ export function canEditPullRequestComment( detail: EditingSubject, diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index ed28cd13e3a00..adfad46a41b89 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -16,6 +16,7 @@ import { AuthSourceControlWriteScope, ThreadId, EnvironmentId, + ORCHESTRATION_V2_WS_METHODS, ScheduledTaskId, WS_METHODS, type AuthSessionState, @@ -121,21 +122,45 @@ describe("command permissions", () => { ), ); - it.effect("uses the target grant for both availability and execution", () => + it.effect("requires the destination settings grant to check OTLP endpoints", () => Effect.scoped( Effect.gen(function* () { const registry = yield* setup; + const check = createCommandPermissions(runtime, WS_METHODS.serverCheckOtlpEndpoint); registry.set(sessions(env), AsyncResult.success(grant(true))); - expect(registry.get(permissions.permissionAtom(env))).toBe(true); - expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env)); - yield* permissions.authorize(registry, env); - registry.set(sessions(other), AsyncResult.success(grant(false))); - expect(registry.get(permissions.permissionAtom(other))).toBe(false); - const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip); - expect(denied._tag).toBe("EnvironmentAuthorizationError"); + expect(registry.get(check.permissionAtom(env))).toBe(false); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(false), + scopes: [AuthSettingsWriteScope], + permissions: [AuthSettingsWriteScope], + }), + ); + expect(registry.get(check.permissionAtom(env))).toBe(true); + yield* check.authorize(registry, env); }), ), ); + + it.effect.each([WS_METHODS.scheduledTasksDelete, ORCHESTRATION_V2_WS_METHODS.stopWorkflow])( + "uses the target grant for both availability and execution of %s", + (method) => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const permissions = createCommandPermissions(runtime, method); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(permissions.permissionAtom(env))).toBe(true); + expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env)); + yield* permissions.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(permissions.permissionAtom(other))).toBe(false); + const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip); + expect(denied._tag).toBe("EnvironmentAuthorizationError"); + }), + ), + ); it.effect("waits for an initial grant", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/pendingUserInput.test.ts b/packages/client-runtime/src/state/pendingUserInput.test.ts new file mode 100644 index 0000000000000..7f5a2b0b84c3d --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPendingUserInputAnswers, + isPendingUserInputOptionSelected, + resolvePendingUserInputAnswer, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, +} from "./pendingUserInput.ts"; + +const singleSelectQuestion = { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { label: "Orchestration-first", description: "Focus on orchestration first" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const nativeChoiceQuestion = { + id: "result", + header: "Result", + question: "Which result should be used?", + options: [ + { value: " first\t", label: "Result", description: "First result" }, + { value: "second", label: "Result", description: "Second result" }, + ], + allowCustomAnswer: false, + multiSelect: false, +} as const; + +describe("resolvePendingUserInputAnswer", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( + question.initialAnswer, + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( + " Edited message\n\n", + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); + expect( + setPendingUserInputCustomAnswer( + question, + { selectedOptionValues: ["Orchestration-first"] }, + "", + ), + ).toEqual({ customAnswer: "" }); + }); + + it("prefers a custom answer over selected options", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + selectedOptionValues: ["Orchestration-first"], + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("does not replace a required choice with a custom answer", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["second"], + customAnswer: "Use another result", + }), + ).toBe("second"); + }); + + it("does not submit labels or unknown values when an option has a value", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["Result", "unknown"], + }), + ).toBeNull(); + }); + + it("matches a label only for an option without a value", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { selectedOptionValues: [" Node.js "] }), + ).toBe("Node.js"); + }); + + it("accepts ready attachment-only answers while preserving selected options", () => { + expect(resolvePendingUserInputAnswer(singleSelectQuestion, { attachmentCount: 1 })).toBe(""); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + selectedOptionValues: ["Node.js"], + }), + ).toBe("Node.js"); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + attachmentsBlocked: true, + }), + ).toBeNull(); + expect( + resolvePendingUserInputAnswer( + { ...singleSelectQuestion, allowCustomAnswer: false }, + { attachmentCount: 1 }, + ), + ).toBeNull(); + }); +}); + +describe("togglePendingUserInputOptionSelection", () => { + it("selects and removes options with the same label independently", () => { + const question = { ...nativeChoiceQuestion, multiSelect: true }; + const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); + const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); + + expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ + result: [" first\t", "second"], + }); + + const secondSelected = togglePendingUserInputOptionSelection( + question, + bothSelected, + " first\t", + ); + expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ + result: ["second"], + }); + }); + + it("submits raw option values and leaves the draft alone for an unknown option", () => { + const first = togglePendingUserInputOptionSelection( + nativeChoiceQuestion, + undefined, + " first\t", + ); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, " first\t")).toBe(true); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, "second")).toBe(false); + expect(buildPendingUserInputAnswers([nativeChoiceQuestion], { result: first })).toEqual({ + result: " first\t", + }); + expect(togglePendingUserInputOptionSelection(nativeChoiceQuestion, first, "Result")).toBe( + first, + ); + }); +}); + +describe("setPendingUserInputCustomAnswer", () => { + it("rejects arbitrary text when the provider only accepts offered options", () => { + expect(setPendingUserInputCustomAnswer(nativeChoiceQuestion, undefined, "Other")).toEqual({}); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { result: { customAnswer: "Other" } }), + ).toBeNull(); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { + result: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, + }), + ).toEqual({ result: "second" }); + }); +}); + +const compatQuestion = { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [{ label: "Keep current envelope", description: "Preserve current wire format" }], + multiSelect: false, +} as const; + +describe("buildPendingUserInputAnswers", () => { + it("returns a canonical answer map for complete prompts", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, compatQuestion], { + scope: { selectedOptionValues: ["Orchestration-first"] }, + compat: { customAnswer: "Keep the current envelope for one release window" }, + }), + ).toEqual({ + scope: "Orchestration-first", + compat: "Keep the current envelope for one release window", + }); + }); +}); diff --git a/packages/client-runtime/src/state/pendingUserInput.ts b/packages/client-runtime/src/state/pendingUserInput.ts new file mode 100644 index 0000000000000..1c71cdb29b4df --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.ts @@ -0,0 +1,176 @@ +/** The question fields answer handling reads. Web and mobile question types both fit it. */ +export interface PendingUserInputQuestion { + readonly id: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly value?: string | undefined }>; + readonly multiSelect?: boolean | undefined; + readonly allowCustomAnswer?: boolean | undefined; + readonly initialAnswer?: string | undefined; +} + +export interface PendingUserInputDraftAnswer { + readonly selectedOptionValues?: ReadonlyArray; + readonly customAnswer?: string; + readonly attachmentCount?: number; + readonly attachmentsBlocked?: boolean; +} + +export type PendingUserInputAnswer = string | ReadonlyArray; + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// Provider option values stay exact, including whitespace. Only options without a value match by label. +function resolvePendingUserInputOptionValue( + question: PendingUserInputQuestion, + value: string, +): string | null { + if (question.options.some((option) => option.value === value)) { + return value; + } + + const label = value.trim(); + return label.length > 0 && + question.options.some((option) => option.value === undefined && option.label.trim() === label) + ? label + : null; +} + +/** The draft's selections that name an offered option, deduplicated, in selection order. */ +export function normalizeSelectedOptionValues( + question: PendingUserInputQuestion, + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set( + value + .map((entry) => resolvePendingUserInputOptionValue(question, entry)) + .filter((entry): entry is string => entry !== null), + ), + ); +} + +/** The answer to submit for one question, or null while it is unanswered. */ +export function resolvePendingUserInputAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, +): PendingUserInputAnswer | null { + if (draft?.attachmentsBlocked) return null; + const customAnswer = + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { + return customAnswer; + } + + const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + if (question.multiSelect) { + return selectedOptionValues.length > 0 + ? selectedOptionValues + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); + } + return ( + selectedOptionValues[0] ?? + customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) + ); +} + +export function setPendingUserInputCustomAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + if (question.allowCustomAnswer === false) { + return draft ?? {}; + } + + const selectedOptionValues = + question.initialAnswer !== undefined || customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + return { + customAnswer, + ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), + }; +} + +export function isPendingUserInputOptionSelected( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): boolean { + if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + return ( + resolvedOptionValue !== null && + normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( + resolvedOptionValue, + ) + ); +} + +/** Toggles an offered option. An unknown option returns the same draft. */ +export function togglePendingUserInputOptionSelection( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): PendingUserInputDraftAnswer { + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + if (resolvedOptionValue === null) { + return draft ?? {}; + } + + if (question.multiSelect) { + const selectedOptionValues = normalizeSelectedOptionValues( + question, + draft?.selectedOptionValues, + ); + const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) + ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) + : [...selectedOptionValues, resolvedOptionValue]; + + return { + customAnswer: "", + ...(nextSelectedOptionValues.length > 0 + ? { selectedOptionValues: nextSelectedOptionValues } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionValues: [resolvedOptionValue], + }; +} + +/** Every question's answer keyed by question id, or null while any is unanswered. */ +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); + if (answer === null) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index ede83d82957a8..03db9b869cc06 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -201,6 +201,12 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + createFromThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-from-thread", + tag: WS_METHODS.projectsCreateFromThread, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index c8ce8b3878744..dadaf2dafb7a5 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -969,6 +969,17 @@ export function createServerEnvironmentAtoms( }), ); + // A track switch's desktop host already on its track's newest build only saved the + // track; nothing restarts. + if ( + result.method === "desktop-app" && + result.desktopUpdateToken === undefined && + result.targetVersion === fromVersion + ) { + atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE); + return result; + } + yield* nudgeReconnectDuringUpdateRestart({ stateChanges: environmentRegistry.stateChanges(target.environmentId), retryNow: environmentRegistry.retryNow(target.environmentId), diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index 20e334288ab00..c40c9fe42d81e 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -59,6 +59,9 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { agents: [ member({ state: "completed", + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", startedAt: Date.UTC(2026, 7, 1, 10, 0, 0), durationMs: 1500, completedAt: Date.UTC(2026, 7, 1, 10, 0, 4, 432), @@ -74,6 +77,9 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { expect(settled?.status).toBe("completed"); expect(settled?.completedAt).toBe("2026-08-01T10:00:04.432Z"); expect(settled?.result).toBe("A1"); + expect(settled?.model).toBe("claude-opus-5[1m]"); + expect(settled?.effort).toBe("xhigh"); + expect(settled?.speed).toBe("standard"); expect(settled?.progress).toBe("Reply with exactly: A1"); expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 }); }); @@ -108,6 +114,9 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { "2026-08-01T10:00:09.000Z", "2026-08-01T10:00:01.500Z", ]); + expect(members?.[0]?.model).toBeNull(); + expect(members?.[0]?.effort).toBeNull(); + expect(members?.[0]?.speed).toBeUndefined(); }, ); }); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index c007bc56b9743..e3807362fbdf6 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -52,6 +52,7 @@ export interface RuntimeSubagent { readonly role: string | null; readonly model: string | null; readonly effort: string | null; + readonly speed?: string | undefined; readonly status: RuntimeSubagentStatus; readonly activationCount: number; readonly usage: SubagentUsage | null; @@ -150,6 +151,8 @@ function workflowMembersToRuntime( kind: "workflow_agent" as const, title: agent.label, model: agent.model ?? null, + effort: agent.effort ?? null, + ...(agent.speed === undefined ? {} : { speed: agent.speed }), status, activationCount: agent.attempt ?? 1, usage: workflowUsage(agent), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 0c93800378797..e1f30998caefb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -409,6 +409,12 @@ export function createEnvironmentThreadDetailAtoms( Atom.withLabel(`environment-thread-status:${key}`), ), ); + const sequenceAtomFamily = Atom.family((key: string) => + Atom.make((get) => get(threadStateValueAtomFamily(key)).sequence).pipe( + Atom.setIdleTTL(0), + Atom.withLabel(`environment-thread-sequence:${key}`), + ), + ); const errorAtomFamily = Atom.family((key: string) => Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( Atom.setIdleTTL(0), @@ -439,6 +445,7 @@ export function createEnvironmentThreadDetailAtoms( quietVisibleTurnItemsAtom: (ref: ScopedThreadRef) => quietVisibleTurnItemsAtomFamily(threadKey(ref)), statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)), + sequenceAtom: (ref: ScopedThreadRef) => sequenceAtomFamily(threadKey(ref)), errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)), historyAtom: (ref: ScopedThreadRef) => historyAtomFamily(threadKey(ref)), }; diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index accf5c98df9a9..ba87930bebbd5 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -151,6 +151,7 @@ export const REASONING_EFFORT_OPTION_IDS: ReadonlyArray = [ "reasoning", "variant", "reasoning_effort", + "thinking_level", ]; export function formatModelSelectionEffort( diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 67b227089ab15..3b53b68b2f4fa 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -14,6 +14,8 @@ export interface EnvironmentThreadState { * disconnect path. */ readonly history: ThreadHistoryMeta; + /** The last server sequence `data` reflects, so readers can tell a write has landed without searching paged-out history. */ + readonly sequence: number; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { @@ -21,4 +23,21 @@ export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { status: "empty", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: 0, }; + +/** + * A thread holds a write once it has applied the write's commit sequence. A bounded + * projection can page the message out later, so finding it is only a fallback for + * servers that do not report the sequence. + */ +export function threadHasCaughtUpTo( + write: { readonly messageId: string; readonly sequence?: number | undefined }, + thread: { + readonly sequence: number; + readonly messages: ReadonlyArray<{ readonly id: string }> | undefined; + }, +): boolean { + if (write.sequence !== undefined && thread.sequence >= write.sequence) return true; + return thread.messages?.some((entry) => entry.id === write.messageId) ?? false; +} diff --git a/packages/client-runtime/src/state/threadSubagents.test.ts b/packages/client-runtime/src/state/threadSubagents.test.ts index 26b9a785c02d3..28544450bfc4e 100644 --- a/packages/client-runtime/src/state/threadSubagents.test.ts +++ b/packages/client-runtime/src/state/threadSubagents.test.ts @@ -56,6 +56,33 @@ const activeRun = run("run-1", "running"); const finishedRun = run("run-1", "completed"); describe("deriveThreadTurnSubagents", () => { + it.each(["running", "completed"] as const)( + "keeps finished workflows after a later %s turn", + (status) => { + const workflow = subagent({ + id: "workflow", + status: "completed", + workflow: { phases: [], agents: [] }, + }); + const turn = deriveThreadTurnSubagents({ + runs: [finishedRun, run("run-2", status)], + subagents: [ + workflow, + subagent({ id: "old", status: "completed" }), + subagent({ + id: "recent", + runId: RunId.make("run-2"), + status, + updatedAt: at("2026-06-20T00:00:09.000Z"), + }), + ], + }); + + expect(turn?.subagents.map((entry) => entry.id)).toEqual(["recent", "workflow"]); + expect(resolveSubagentPillSegment(turn)).not.toBeNull(); + }, + ); + it("scopes the roster to the active run and orders it by start time", () => { const turn = deriveThreadTurnSubagents({ runs: [run("run-0", "completed"), activeRun], @@ -264,15 +291,16 @@ describe("deriveThreadTurnSubagents", () => { const turn = deriveThreadTurnSubagents({ runs: [activeRun], subagents: [ - subagent({ id: "a", status: "running" }), + subagent({ id: "a", status: "completed" }), subagent({ id: "b", status: "idle" }), subagent({ id: "c", status: "failed" }), ], }); - expect(turn?.liveCount).toBe(1); - expect(turn?.settledCount).toBe(1); + expect(turn?.liveCount).toBe(0); + expect(turn?.settledCount).toBe(2); expect(turn?.subagents).toHaveLength(3); + expect(resolveSubagentPillSegment(turn)?.label).toBe("2 done"); }); it("keeps settled workflows reachable without counting idle agents as done", () => { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c8c212db41ca1..eaf1751a1c2d2 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -678,7 +678,7 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ cached: BASE_PROJECTION }); yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1)); - yield* awaitThreadState( + const live = yield* awaitThreadState( harness.observed, (value) => value.status === "live" && @@ -686,6 +686,8 @@ describe("EnvironmentThreads", () => { value.data.value.thread.title === "Live title", ); + // Readers compare this to a command's commit sequence; it moves with the data it describes. + expect(live.sequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); expect(yield* Ref.get(harness.loaderCalls)).toBe(0); }), @@ -1288,6 +1290,7 @@ describe("EnvironmentThreads", () => { status: "cached", error: Option.none(), history: { ...EMPTY_THREAD_HISTORY_META, historyCursor: null, expanded: true }, + sequence: CACHED_SNAPSHOT_SEQUENCE, }, sequence: CACHED_SNAPSHOT_SEQUENCE, persisted: true, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 3cb4192f0ab6c..c7b22d9dcdff0 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -224,6 +224,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) : Option.none(); const cachedThread = Option.map(cached, (snapshot) => snapshot.projection); + // Seed the resume cursor from the cached snapshot so a warm cache can catch up + // via `afterSequence` instead of re-downloading the full thread body. + const initialSequence = + retained?.sequence ?? + Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { @@ -234,15 +239,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make onNone: () => EMPTY_THREAD_HISTORY_META, onSome: historyMetaFromCachedSnapshot, }), + sequence: initialSequence, }; const state = yield* SubscriptionRef.make(initialState); const canLoadHistory = Option.isSome(httpClient) && Option.isSome(historyController); const acceptsBoundedSocketSnapshots = yield* Ref.make(canLoadHistory); - // Seed the resume cursor from the cached snapshot so a warm cache can catch up - // via `afterSequence` instead of re-downloading the full thread body. - const initialSequence = - retained?.sequence ?? - Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const lastSequence = yield* SubscriptionRef.make(initialSequence); let committed: ThreadResumeSnapshot = { state: initialState, @@ -400,6 +401,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ) { const waiting = yield* Ref.get(awaitingCompletion); + const sequence = yield* SubscriptionRef.get(lastSequence); // Atomic with concurrent history meta updates: never get-then-set the whole // state when only the projection changes. Bounded installs pass history so // projection + cursor persist together in one enqueue. @@ -420,6 +422,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : ("live" as const), error: previous.error, history, + sequence, }; }); // Active projections can update many times per second and retain large tool @@ -451,6 +454,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: "deleted", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: yield* SubscriptionRef.get(lastSequence), }); yield* remember; if (resumeCache !== undefined && resumeCache.owner !== owner) return; @@ -571,7 +575,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (applied._tag !== "noop") result = applied; if (applied._tag === "delete") break; } - return [result, current]; + // A batch that changed nothing keeps its state object, so readers do not re-render. + return [result, result._tag === "noop" ? current : { ...current, sequence }]; }, ); @@ -796,7 +801,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); if (result._tag !== "loaded") return result; - if (complete) yield* remember; + if (complete) yield* applyLock.withPermits(1)(remember); nextCursor = keepPaging ? page.nextCursor : null; } if (nextCursor === null) return { _tag: "loaded" } as const; diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c87a9fed41033..2a032000896fb 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.3", + "version": "0.1.5", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 3cd80feea6b73..f9564b2e2441c 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -33,6 +33,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, // Writing a guide runs a model on this environment's providers, which reading one does not. [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateFromThread]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, // Signing in replaces the server's GitHub credential, as saving a token in Settings does. 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..24b72dbe26d7a 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), @@ -99,6 +127,33 @@ export const DeviceEasAccount = Schema.Struct({ }); export type DeviceEasAccount = typeof DeviceEasAccount.Type; +export const DeviceSimlockIdentityInput = Schema.Struct({ + /** Without one, the gateway this machine's agent-device points at. */ + gatewayUrl: Schema.optional(TrimmedString), + /** A saved Simlock host whose stored token is checked when no new token is given. */ + hostId: Schema.optional(DeviceHostId), + agentToken: Schema.optional(TrimmedString), +}); +export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type; + +export const DeviceSimlockIdentity = Schema.Struct({ + gatewayUrl: Schema.String, + /** The label the gateway gave the token. */ + signedInAs: Schema.String, + /** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */ + tokenSource: Schema.Literals(["entered", "settings", "env"]), +}); +export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type; + +export class DeviceSimlockIdentityError extends Schema.TaggedError()( + "DeviceSimlockIdentityError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + /** Checking an Expo credential failed; no device host is involved yet. */ export class DeviceEasAccountError extends Schema.TaggedError()( "DeviceEasAccountError", @@ -161,7 +216,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 +261,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 +286,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 +300,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/environment.ts b/packages/contracts/src/environment.ts index a65a91e0d3921..75a546bed875f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -110,6 +110,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinReorder: Schema.optionalKey(Schema.Boolean), threadActiveReorder: Schema.optionalKey(Schema.Boolean), threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), + threadProjectFromScratch: Schema.optionalKey(Schema.Boolean), threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), threadVisitedTracking: Schema.optionalKey(Schema.Boolean), threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), @@ -132,6 +133,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ ignoredFileListing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), + /** The desktop app switches update tracks when a server update request names one. */ + desktopAppChannelSwitch: Schema.optionalKey(Schema.Boolean), /** Subscribe to command output only when advertised; older servers never send it. */ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), /** Hosts headless Chromium tabs streamed over /api/preview-stream for clients without a local runtime. */ @@ -159,6 +162,8 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ platform: ExecutionEnvironmentPlatform, serverVersion: TrimmedNonEmptyString, orchestrationProtocolVersion: Schema.optionalKey(Schema.Int), + /** Highest migration this server's database holds; absent on older servers. */ + databaseMigration: Schema.optionalKey(Schema.Int), capabilities: ExecutionEnvironmentCapabilities, }); export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f760113cff3d4..2bdcf5503ec24 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -298,12 +298,21 @@ export interface DesktopUpdateReleaseNote { version: string; items: ReadonlyArray; totalItems: number; + /** ISO time from the update feed for the offered version, else `YYYY-MM-DD` from a prerelease version. */ + releasedAt?: string; + /** Generated summaries only: the highlights paragraph. `items` then holds the fixes. */ + intro?: string; + /** Generated summaries only, for example "275 changes from 22 contributors". */ + stats?: string; } export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ version: Schema.String, items: Schema.Array(Schema.String), totalItems: Schema.Number, + releasedAt: Schema.optionalKey(Schema.String), + intro: Schema.optionalKey(Schema.String), + stats: Schema.optionalKey(Schema.String), }); export const DesktopUpdateStateSchema = Schema.Struct({ @@ -1106,10 +1115,15 @@ export interface DesktopBridge { onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getWindowZoomFactor: () => number; getUpdateState: () => Promise; - setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; + /** `minimumVersion` is a track switch's checked release; nothing older is offered after. */ + setUpdateChannel: ( + channel: DesktopUpdateChannel, + minimumVersion?: string, + ) => Promise; checkForUpdate: () => Promise; downloadUpdate: () => Promise; - installUpdate: () => Promise; + /** With a version, installs only that downloaded build. */ + installUpdate: (expectedVersion?: string) => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; cliCommand: { getState: () => Promise; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 2414d3cd01984..3782307171e87 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -358,6 +358,9 @@ describe("orchestration V2 contracts", () => { index: 1, label: "Member", state: "completed" as const, + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", prompt: preview, result: preview, }, @@ -379,10 +382,16 @@ describe("orchestration V2 contracts", () => { expect(decode(json).workflow?.agents[0]).toMatchObject({ prompt: preview, result: preview, + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", }); expect(decode(incoming).workflow?.agents[0]).toMatchObject({ prompt: preview, result: preview, + model: "claude-opus-5[1m]", + effort: "xhigh", + speed: "standard", }); } }, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 56d910b752deb..747d05e7ce4eb 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -787,6 +787,8 @@ export const OrchestrationV2WorkflowAgent = Schema.Struct({ phaseIndex: Schema.optional(NonNegativeInt), phaseTitle: Schema.optional(TrimmedNonEmptyString), model: Schema.optional(TrimmedNonEmptyString), + effort: Schema.optional(TrimmedNonEmptyString), + speed: Schema.optional(TrimmedNonEmptyString), attempt: Schema.optional(NonNegativeInt), totalTokens: Schema.optional(NonNegativeInt), toolCalls: Schema.optional(NonNegativeInt), @@ -3333,6 +3335,13 @@ const OrchestrationV2InternalCommand = Schema.Union([ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), }), + /** Moves a thread to a project rooted at the folder it already works in, so its cwd never changes. */ + Schema.Struct({ + type: Schema.Literal("thread.project.adopt-folder"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + }), Schema.Struct({ type: Schema.Literal("thread.pull-request-watch.sync"), commandId: CommandId, @@ -3571,6 +3580,8 @@ export const OrchestrationV2ThreadLaunchResult = Schema.Struct({ threadId: ThreadId, projection: OrchestrationV2ThreadProjection, resumed: Schema.Boolean, + /** The initial message's commit sequence. Older servers omit it. */ + sequence: Schema.optionalKey(NonNegativeInt), }); export type OrchestrationV2ThreadLaunchResult = typeof OrchestrationV2ThreadLaunchResult.Type; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 12934a7f8ed70..79e164b71e93b 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -10,6 +10,7 @@ import { NonNegativeInt, PositiveInt, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, type UnknownUnionMember, @@ -527,6 +528,16 @@ export const ProjectCreateNewResult = Schema.Struct({ }); export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; +export const ProjectCreateFromThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ProjectCreateFromThreadInput = typeof ProjectCreateFromThreadInput.Type; + +export const ProjectCreateFromThreadResult = Schema.Struct({ + projectId: ProjectId, +}); +export type ProjectCreateFromThreadResult = typeof ProjectCreateFromThreadResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index e38d5fa6cf95a..64111ab4742b1 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -244,6 +244,7 @@ export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities export const PullRequestEditCapabilities = Schema.Struct({ changeRequest: Schema.Boolean, comment: Schema.Boolean, + maintainerEdits: Schema.optional(Schema.Boolean), }); export type PullRequestEditCapabilities = typeof PullRequestEditCapabilities.Type; @@ -295,6 +296,7 @@ export const PullRequestViewerPermissions = Schema.Struct({ updateMethods: Schema.optional(ForwardCompatibleArray(PullRequestUpdateMethod)), labels: Schema.optional(Schema.Boolean), editChangeRequest: Schema.optional(Schema.Boolean), + maintainerEdits: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; @@ -589,6 +591,7 @@ export const PullRequestDetail = Schema.Struct({ autoMergeEnabled: Schema.optional(Schema.Boolean), autoMergeMethod: Schema.optional(PullRequestMergeMethod), workflowApprovalsRequired: Schema.optional(NonNegativeInt), + maintainerCanModify: Schema.optional(Schema.Boolean), }); export type PullRequestDetail = typeof PullRequestDetail.Type; @@ -746,6 +749,7 @@ export const PullRequestUpdateInput = Schema.Struct({ ...PullRequestRef.fields, title: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(1024))), body: Schema.optional(Schema.String.check(Schema.isMaxLength(65_536))), + maintainerCanModify: Schema.optional(Schema.Boolean), }); export type PullRequestUpdateInput = typeof PullRequestUpdateInput.Type; diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index bce626b0c3668..5a004f93a2a4c 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { HostPowerSnapshot } from "./background.ts"; -import { DesktopUpdateStateSchema } from "./ipc.ts"; +import { DesktopUpdateChannelSchema, DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 3 as const; @@ -323,6 +323,9 @@ export const DesktopTelemetryRequestDesktopUpdate = Schema.Struct({ version: Schema.Literal(1), type: Schema.Literal("requestDesktopUpdate"), requestId: TrimmedNonEmptyString, + /** A track switch: move to this channel first, and install only `targetVersion`. */ + channel: Schema.optionalKey(DesktopUpdateChannelSchema), + targetVersion: Schema.optionalKey(TrimmedNonEmptyString), }); export type DesktopTelemetryRequestDesktopUpdate = typeof DesktopTelemetryRequestDesktopUpdate.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e4823f30272bd..aae86bd9db465 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -230,6 +230,8 @@ import { import { ProjectCreateNewInput, ProjectCreateNewResult, + ProjectCreateFromThreadInput, + ProjectCreateFromThreadResult, ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, @@ -293,6 +295,9 @@ import { DeviceEasAccount, DeviceEasAccountError, DeviceEasAccountInput, + DeviceSimlockIdentity, + DeviceSimlockIdentityError, + DeviceSimlockIdentityInput, DeviceHostSummary, DeviceOpenInput, DeviceServiceState, @@ -402,6 +407,7 @@ export const WS_METHODS = { projectsMutate: "projects.mutate", projectsEnsureScratch: "projects.ensureScratch", projectsCreateNew: "projects.createNew", + projectsCreateFromThread: "projects.createFromThread", shellOpenInEditor: "shell.openInEditor", @@ -473,6 +479,7 @@ export const WS_METHODS = { deviceList: "device.list", deviceTestHost: "device.testHost", deviceEasAccount: "device.easAccount", + deviceSimlockIdentity: "device.simlockIdentity", deviceOpen: "device.open", deviceClose: "device.close", deviceShutdown: "device.shutdown", @@ -1309,6 +1316,12 @@ const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +const WsProjectsCreateFromThreadRpc = Rpc.make(WS_METHODS.projectsCreateFromThread, { + payload: ProjectCreateFromThreadInput, + success: ProjectCreateFromThreadResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1613,6 +1626,12 @@ const WsDeviceEasAccountRpc = Rpc.make(WS_METHODS.deviceEasAccount, { error: Schema.Union([DeviceEasAccountError, EnvironmentAuthorizationError]), }); +const WsDeviceSimlockIdentityRpc = Rpc.make(WS_METHODS.deviceSimlockIdentity, { + payload: DeviceSimlockIdentityInput, + success: DeviceSimlockIdentity, + error: Schema.Union([DeviceSimlockIdentityError, EnvironmentAuthorizationError]), +}); + const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { payload: DeviceListInput, success: DeviceServiceState, @@ -2033,6 +2052,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, WsProjectsCreateNewRpc, + WsProjectsCreateFromThreadRpc, WsProjectsWriteFileRpc, WsProjectsMutateRpc, WsShellOpenInEditorRpc, @@ -2088,6 +2108,7 @@ export const WsRpcGroup = RpcGroup.make( WsDeviceListRpc, WsDeviceTestHostRpc, WsDeviceEasAccountRpc, + WsDeviceSimlockIdentityRpc, WsDeviceOpenRpc, WsDeviceCloseRpc, WsDeviceShutdownRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 134d8160642fc..92dc88be173a8 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -28,6 +28,7 @@ import { RuntimeMode } from "./providerPolicy.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; import { ServerSettings } from "./settings.ts"; +import { DesktopUpdateChannelSchema } from "./ipc.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -879,6 +880,10 @@ export class ServerProviderUpdateError extends Schema.TaggedError entry.slug), promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), ...(registryAgentId === "factory-droid" diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b88e31d203948..398eac2170e70 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -76,6 +76,7 @@ import { parsePermissionRequest, parseSessionUpdateEvent, toolCallVisibleOutputChanged, + withTrackedPermissionToolCall, type AcpPlanUpdate, type AcpAgentTerminalState, type AcpSessionModeState, @@ -287,7 +288,7 @@ export interface AcpAdapterV2Flavor { request: EffectAcpSchema.ReadTextFileRequest, cwd: string | null, ) => Effect.Effect; - readonly writeTextFile: ( + readonly writeTextFile?: ( request: EffectAcpSchema.WriteTextFileRequest, cwd: string | null, ) => Effect.Effect; @@ -1992,7 +1993,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( clientCapabilities: { fs: { readTextFile: flavor.clientFileSystem !== undefined, - writeTextFile: flavor.clientFileSystem !== undefined, + writeTextFile: flavor.clientFileSystem?.writeTextFile !== undefined, }, terminal: clientTerminals !== undefined, elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) }, @@ -5957,11 +5958,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)), ), ); - yield* targetRuntime.handleWriteTextFile((request) => - clientPolicyContext.pipe( - Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)), - ), - ); + const writeTextFile = clientFileSystem.writeTextFile; + if (writeTextFile !== undefined) { + yield* targetRuntime.handleWriteTextFile((request) => + clientPolicyContext.pipe( + Effect.flatMap(({ policy }) => writeTextFile(request, policy.cwd)), + ), + ); + } } if (handlerOptions.mcp !== false) { yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge); @@ -5974,8 +5978,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( projectAcpRuntimeSessionUpdate(handlerGeneration, rawNotification), ); } - yield* targetRuntime.handleRequestPermission((params, requestContext) => + yield* targetRuntime.handleRequestPermission((nativeParams, requestContext) => Effect.gen(function* () { + const trackedTurn = yield* Ref.get(activeTurn); + const params = + trackedTurn !== null && nativeParams.sessionId === (yield* Ref.get(activeSessionId)) + ? withTrackedPermissionToolCall( + nativeParams, + trackedTurn.tools.get(nativeParams.toolCall.toolCallId), + ) + : nativeParams; const transportRequestId = requestContext.requestId; const permissionQuestion = flavor.extractPermissionQuestion?.(params); if (permissionQuestion !== undefined) { diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index 70682ec4824a5..e60671c775d51 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -17,6 +17,7 @@ import { sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, toolCallProgressLength, + withTrackedPermissionToolCall, type AcpToolCallState, } from "./runtimeModel.ts"; @@ -432,6 +433,40 @@ describe("AcpRuntimeModel", () => { }); }); + it("keeps an explicit permission request kind over the tracked tool call's kind", () => { + const request = withTrackedPermissionToolCall( + { + sessionId: "session-1", + options: [{ optionId: "allow-once", name: "Allow once", kind: "allow_once" }], + toolCall: { toolCallId: "tool-1", kind: "read" }, + }, + { + toolCallId: "tool-1", + kind: "execute", + data: { toolCallId: "tool-1", title: "git status", rawInput: { command: "git status" } }, + }, + ); + + expect(parsePermissionRequest(request)).toMatchObject({ kind: "read", detail: "git status" }); + }); + + it("keeps explicitly cleared permission request fields over the tracked tool call", () => { + const request = withTrackedPermissionToolCall( + { + sessionId: "session-1", + options: [{ optionId: "allow-once", name: "Allow once", kind: "allow_once" }], + toolCall: { toolCallId: "tool-1", kind: null, title: null, rawInput: null }, + }, + { + toolCallId: "tool-1", + kind: "execute", + data: { toolCallId: "tool-1", title: "git status", rawInput: { command: "git status" } }, + }, + ); + + expect(parsePermissionRequest(request).kind).toBe("unknown"); + }); + it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( "\n", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 87f24c5afdbaa..43fa2bf758e2f 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1179,6 +1179,29 @@ export function embeddedTerminalIdsFromSessionUpdate( return terminalIds.length === 0 ? undefined : { toolCallId: update.toolCallId, terminalIds }; } +/** ACP lets a permission request name its tool call by ID only; omitted (not null) fields come from the tracked call. */ +export function withTrackedPermissionToolCall( + params: EffectAcpSchema.RequestPermissionRequest, + tracked: AcpToolCallState | undefined, +): EffectAcpSchema.RequestPermissionRequest { + if (tracked === undefined) return params; + const { title, kind, rawInput } = params.toolCall; + return { + ...params, + toolCall: { + ...params.toolCall, + ...(title === undefined && typeof tracked.data.title === "string" + ? { title: tracked.data.title } + : {}), + // Unrecognised kinds map to command approvals, so only a tracked execute kind may grant execution. + ...(kind === undefined && tracked.kind === "execute" ? { kind: "execute" as const } : {}), + ...(rawInput === undefined && tracked.data.rawInput !== undefined + ? { rawInput: tracked.data.rawInput } + : {}), + }, + }; +} + export function parsePermissionRequest( params: EffectAcpSchema.RequestPermissionRequest, ): AcpPermissionRequest { diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 668502f93e432..7794107824c9b 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -6,6 +6,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -138,6 +139,116 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("invalidates cached usage and queues one refresh behind an in-flight refresh", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const cache = yield* Cache.makeWith( + () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { + checkedAt: refreshedSnapshot.checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: count === 1 ? 20 : 100, + }, + ], + }, + } satisfies ServerProvider; + }), + { capacity: 1, timeToLive: () => "5 minutes" }, + ); + const warmed = yield* Cache.get(cache, "claude"); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(warmed), + checkProvider: Cache.get(cache, "claude"), + invalidateUsageCache: Cache.invalidateAll(cache), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + assert.strictEqual(yield* Ref.get(calls), 1); + const refresh = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const queued = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(calls), 2); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(refresh); + yield* Fiber.join(queued); + assert.strictEqual(yield* Ref.get(calls), 3); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect("keeps the waiting usage refresh's claim when the running one is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const usageProbeStarted = yield* Deferred.make(); + const statusProbeStarted = yield* Deferred.make(); + const releaseStatusProbe = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(usageProbeStarted, undefined); + return yield* Effect.never; + } + if (count === 3) { + yield* Deferred.succeed(statusProbeStarted, undefined); + yield* Deferred.await(releaseStatusProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [] }, + } satisfies ServerProvider; + }), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + const running = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(usageProbeStarted); + const status = yield* provider.refresh.pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.interrupt(running); + yield* Deferred.await(statusProbeStarted); + const extra = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseStatusProbe, undefined); + yield* Fiber.join(status); + yield* Fiber.join(waiting); + yield* Fiber.join(extra); + assert.strictEqual(yield* Ref.get(calls), 4); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index f40baee525c8d..41ff65b0f294b 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -44,6 +44,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly invalidateUsageCache?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -60,6 +61,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( > { const host = yield* ProviderHost.ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); + const usageRefreshQueued = yield* Ref.make(null); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -216,6 +218,38 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); + const refreshUsage = Effect.fn("ManagedServerProvider.refreshUsage")(function* () { + const { snapshot } = yield* Ref.get(snapshotStateRef); + if ( + !snapshot.enabled || + !snapshot.usageLimits || + snapshot.usageLimits.unavailable?.reason === "unsupported" + ) + return; + // An in-flight refresh may have read cached usage, so one refresh waits behind it. + const claim = Symbol(); + const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); + yield* Effect.uninterruptibleMask((restore) => + Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim]).pipe( + Effect.flatMap((held) => + held + ? Effect.void + : restore( + refreshSemaphore.withPermits(1)( + release.pipe( + Effect.andThen(input.invalidateUsageCache ?? Effect.void), + Effect.andThen(input.getSettings), + Effect.flatMap((settings) => + applySnapshotBase(settings, { forceRefresh: true }), + ), + ), + ), + ).pipe(Effect.onInterrupt(() => release)), + ), + ), + ); + }); + const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only @@ -304,6 +338,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( .resolveMaintenance({ fresh: true }) .pipe(Effect.andThen(refreshSnapshot), Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, + refreshUsage: refreshUsage(), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index c9c877d29fe20..88f2c1f113848 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -1,4 +1,8 @@ -import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; +import type { + ProviderUsageLimitsUpdate, + ServerProvider, + ServerSettingsError, +} from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; @@ -10,6 +14,7 @@ export interface ManagedServerProvider { }) => Effect.Effect; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; + readonly refreshUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; /** Merge windows by ID; updates with no usable window leave the snapshot untouched. */ readonly applyUsageLimits: ( diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 7f0decb49af3f..5986d09506d09 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -43,6 +43,21 @@ export function cursorSdkFailureMessage(error: CursorAgentSdkRunnerError): strin : `Cursor Agent SDK ${error.method} failed: ${reason}`; } +// The SDK maps backend details to codes like PRO_USER_USAGE_LIMIT or RATE_LIMITED, without a status. +const CURSOR_USAGE_LIMIT_CODE = + /^(?:resource_exhausted|rate_limited(?:_changeable)?|\w*_usage_limit|\w*_rate_limit(?:_exceeded)?)$/i; + +export function isCursorUsageLimitError(cause: unknown): boolean { + const error = isCursorAgentSdkRunnerError(cause) ? cause.cause : cause; + if (typeof error !== "object" || error === null) return false; + const code = Reflect.get(error, "code"); + return ( + Reflect.get(error, "name") === "RateLimitError" || + Reflect.get(error, "status") === 429 || + (typeof code === "string" && CURSOR_USAGE_LIMIT_CODE.test(code)) + ); +} + const GENERIC_SDK_ERROR_CODES = new Set(["", "error", "unknown"]); function sdkErrorReason(cause: unknown): string | undefined { diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 5e7093b67ae21..062089fffa0c3 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -214,6 +214,15 @@ describe("CursorAdapterV2", () => { ); it.effect.each([ + { + reason: "the SDK's detailed plan usage limit code", + wait: Effect.succeed({ + id: "native-cursor-run", + status: "error" as const, + error: { message: "You've hit your usage limit", code: "PRO_USER_USAGE_LIMIT" }, + }), + failure: { class: "usage_limit", message: "You've hit your usage limit" }, + }, { reason: "the error Cursor's run ended with", wait: Effect.succeed({ @@ -232,7 +241,7 @@ describe("CursorAdapterV2", () => { }, }, { - reason: "the SDK error that broke the run", + reason: "the SDK rate limit that broke the run", wait: Effect.fail( new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "run.wait", @@ -243,7 +252,7 @@ describe("CursorAdapterV2", () => { }), ), failure: { - class: "transport_error", + class: "usage_limit", message: "Cursor Agent SDK run.wait failed: Too many requests (resource_exhausted, HTTP 429)", }, @@ -1140,6 +1149,227 @@ describe("CursorAdapterV2", () => { ), ); + it.effect("projects subagent directory trees Cursor serialized without empty lists", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-task-ls-" }); + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-task-ls-thread"); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + // The SDK serializes subagent steps with protobuf toJson(), which omits empty repeated fields. + const conversationSteps = [ + { + toolCall: { + toolCallId: "tool:ls", + lsToolCall: { + args: { path: workspace }, + result: { + success: { + directoryTreeRoot: { + absPath: workspace, + childrenWereProcessed: true, + childrenDirs: [ + { + absPath: path.join(workspace, "src"), + childrenFiles: [{ name: "index.ts" }], + childrenWereProcessed: true, + numFiles: 1, + }, + ], + }, + }, + }, + }, + }, + }, + { + toolCall: { + toolCallId: "tool:lints", + readLintsToolCall: { args: {}, result: { success: {} } }, + }, + }, + { + toolCall: { + toolCallId: "tool:lints-clean-file", + readLintsToolCall: { + args: {}, + result: { success: { fileDiagnostics: [{ path: "src/clean.ts" }] } }, + }, + }, + }, + { + toolCall: { + toolCallId: "tool:mcp", + mcpToolCall: { + args: { providerIdentifier: "docs", toolName: "lookup", args: { query: "ls" } }, + result: { success: {} }, + }, + }, + }, + ]; + const adapter = yield* makeCursorAdapterV2({ + instanceId, + environment: { HOME: workspace }, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-task-ls", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.gen(function* () { + const args = { + description: "Explore", + prompt: "List the workspace.", + subagentType: { kind: "unspecified" as const }, + agentId: "cursor-task-agent", + mode: "unspecified" as const, + }; + yield* input.onDelta!({ + type: "tool-call-started", + modelCallId: "model-call", + callId: "task-call", + toolCall: { type: "task", args }, + }); + yield* input.onDelta!({ + type: "tool-call-completed", + modelCallId: "model-call", + callId: "task-call", + toolCall: { + type: "task", + args, + result: { + status: "success", + value: { + conversationSteps, + isBackground: false, + backgroundReason: "unspecified", + }, + }, + }, + }); + return { + agentId: "native-cursor-task-ls", + runId: "native-cursor-run", + wait: Effect.succeed({ + id: "native-cursor-run", + requestId: "native-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + cancel: Effect.void, + }; + }).pipe(Effect.orDie), + }), + }), + Effect.provide(TestProviderHost.layer({ cwd: workspace })), + ); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-task-ls-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make("cursor-task-ls-run"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("cursor-task-ls-attempt"), + rootNodeId: NodeId.make("cursor-task-ls-root"), + appThread: { + id: threadId, + projectId: ProjectId.make("cursor-task-ls-project"), + createdBy: "user", + creationSource: "web", + title: "Cursor subagent ls", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + message: { + messageId: MessageId.make("cursor-task-ls-message"), + createdBy: "user", + creationSource: "web", + text: "List the workspace with a subagent.", + attachments: [], + }, + }); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const terminal = events.find((event) => event.type === "turn.terminal"); + assert.equal(terminal?.status, "completed"); + const childThreadId = events.findLast((event) => event.type === "subagent.updated")?.subagent + .childThreadId; + assert.isDefined(childThreadId); + assert.deepEqual( + events.flatMap((event): ReadonlyArray => { + if (event.type !== "turn_item.updated" || event.turnItem.threadId !== childThreadId) { + return []; + } + const item = event.turnItem; + return item.type === "file_search" + ? [{ status: item.status, pattern: item.pattern, results: item.results ?? [] }] + : item.type === "dynamic_tool" + ? [{ status: item.status, toolName: item.toolName, output: item.output }] + : []; + }), + [ + { + status: "completed", + pattern: workspace, + results: [{ fileName: path.join(workspace, "src", "index.ts") }], + }, + { status: "completed", pattern: "", results: [] }, + { status: "completed", pattern: "", results: [] }, + { + status: "completed", + toolName: "mcp__docs__lookup", + output: { content: [], isError: false }, + }, + ], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), + ); + it.effect("names a custom model Cursor's account catalog rejects", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 1d98c4523ffef..72aca919b25f4 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -625,6 +625,29 @@ function nestedGrepWorkspaceResults(success: Record): Record { + return Array.isArray(value) ? value : []; +} + +function nestedLsDirectory(rawNode: unknown): CursorLsDirectoryNode { + const node = unknownRecord(rawNode); + return { + absPath: typeof node?.absPath === "string" ? node.absPath : "", + childrenDirs: nestedArray(node?.childrenDirs).map(nestedLsDirectory), + childrenFiles: nestedArray(node?.childrenFiles).flatMap((rawFile) => { + const name = unknownRecord(rawFile)?.name; + return typeof name === "string" ? [{ name }] : []; + }), + childrenWereProcessed: node?.childrenWereProcessed === true, + fullSubtreeExtensionCounts: Object.fromEntries( + Object.entries(unknownRecord(node?.fullSubtreeExtensionCounts) ?? {}).filter( + (entry): entry is [string, number] => typeof entry[1] === "number", + ), + ), + numFiles: typeof node?.numFiles === "number" ? node.numFiles : 0, + }; +} + export function nestedToolCallFromEnvelope( envelope: Record, ): { readonly callId: string; readonly toolCall: ToolCall } | undefined { @@ -730,6 +753,66 @@ export function nestedToolCallFromEnvelope( } as unknown as ToolCall, }; } + case "lsToolCall": + return { + callId: nestedCallId, + toolCall: { + type: "ls", + args: { path: typeof args.path === "string" ? args.path : "" }, + result: nestedToolResult(call.result, (value) => ({ + directoryTreeRoot: nestedLsDirectory(value.directoryTreeRoot), + })), + } as unknown as ToolCall, + }; + case "readLintsToolCall": + return { + callId: nestedCallId, + toolCall: { + type: "readLints", + args: { + paths: nestedArray(args.paths).filter( + (path): path is string => typeof path === "string", + ), + }, + result: nestedToolResult(call.result, (value) => ({ + fileDiagnostics: nestedArray(value.fileDiagnostics).map((rawFile) => { + const file = unknownRecord(rawFile); + return { + path: typeof file?.path === "string" ? file.path : "", + diagnostics: nestedArray(file?.diagnostics).flatMap((rawDiagnostic) => { + const diagnostic = unknownRecord(rawDiagnostic); + return diagnostic === undefined + ? [] + : [ + { + ...diagnostic, + message: typeof diagnostic.message === "string" ? diagnostic.message : "", + }, + ]; + }), + }; + }), + totalFiles: typeof value.totalFiles === "number" ? value.totalFiles : 0, + totalDiagnostics: + typeof value.totalDiagnostics === "number" ? value.totalDiagnostics : 0, + })), + } as unknown as ToolCall, + }; + case "mcpToolCall": + return { + callId: nestedCallId, + toolCall: { + type: "mcp", + args, + result: nestedToolResult(call.result, (value) => ({ + content: nestedArray(value.content).flatMap((rawPart) => { + const part = unknownRecord(rawPart); + return part === undefined ? [] : [part]; + }), + isError: value.isError === true, + })), + } as unknown as ToolCall, + }; default: { const type = wrapperName.slice(0, -"ToolCall".length); return { @@ -2423,7 +2506,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( cause: result.error, // makeProviderFailure never reads a cause's own text. message: result.error?.message, - class: "provider_error", + class: CursorAgentSdk.isCursorUsageLimitError(result.error) + ? "usage_limit" + : "provider_error", }) : makeProviderFailure({ cause: transportFailure, @@ -2449,7 +2534,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( message: CursorAgentSdk.isCursorAgentSdkRunnerError(cause) ? CursorAgentSdk.cursorSdkFailureMessage(cause) : undefined, - class: "transport_error", + class: CursorAgentSdk.isCursorUsageLimitError(cause) + ? "usage_limit" + : "transport_error", }), }), }); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 759545fff0d19..41da6bd0ff435 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -240,6 +240,51 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect("classifies APIError 429 as a usage limit", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness("usage-limit", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "APIError", + data: { + message: "Provider rejected request", + statusCode: 429, + isRetryable: true, + }, + }, + }, + }), + ); + const terminal = (yield* Fiber.join(received)).find( + (event) => event.type === "turn.terminal", + ); + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.failure?.class, "usage_limit"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("applies a changed runtime policy to the session on resume and turn start", () => Effect.gen(function* () { const updates: Array<{ sessionID: string; permission: unknown }> = []; diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index ef85a7e70b29b..2a851188d076e 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -2643,6 +2643,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function (state): state is OpenCodeThreadState => state !== undefined, ); const message = openCodeErrorMessage(event); + const error = event.properties.error; + const usageLimit = error?.name === "APIError" && error.data.statusCode === 429; if ( !isMessageAbortedError(event) && (event.properties.sessionID === undefined || @@ -2659,8 +2661,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function { failure: makeProviderFailure({ message, - code: event.properties.error?.name ?? null, - class: "provider_error", + code: error?.name ?? null, + class: usageLimit ? "usage_limit" : "provider_error", }), threadDisposition: event.properties.sessionID === undefined ? "broken" : "reusable", diff --git a/packages/shared/src/changedFileKind.ts b/packages/shared/src/changedFileKind.ts index 9fd5c0c7edc79..01cbe899b26ff 100644 --- a/packages/shared/src/changedFileKind.ts +++ b/packages/shared/src/changedFileKind.ts @@ -67,6 +67,11 @@ const NOT_DOCS_NAME = /^(requirements.*|constraints.*|CMakeLists|robots|llms)\.t * output count as generated, and test support (mocks, fixtures, snapshots) is kept apart from the * tests themselves, and prose (Markdown, plain text) is its own kind, so a reviewer can see how much of a change is logic. */ +export function isTestChangedFile(path: string): boolean { + const kind = classifyChangedFile(path); + return kind === "tests" || kind === "mocks"; +} + export function classifyChangedFile(path: string): ChangedFileKind { const segments = path.replaceAll("\\", "/").split("/"); const name = segments.pop() ?? ""; diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 94bd3cffa168c..66777cc044478 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -1,3 +1,8 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { compareSemverVersions } from "./semver.ts"; + const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; export const CLI_RELEASES_PAGE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases`; export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; @@ -68,6 +73,32 @@ export function cliReleaseChannelOf(version: string): CliReleaseChannel { return channel === "nightly" || channel === "preview" ? channel : "stable"; } +/** Whether updating between these versions crosses Stable and Nightly. Preview builds cross nothing. + * Hosts refuse a crossing outside a track switch, which checks the database first. */ +export function crossesReleaseTrack(fromVersion: string, toVersion: string): boolean { + const from = cliReleaseChannelOf(fromVersion); + const to = cliReleaseChannelOf(toVersion); + return from !== to && from !== "preview" && to !== "preview"; +} + +/** The desktop update track a build belongs to. Preview builds have no feed and read as Stable. */ +export function desktopUpdateChannelOf(version: string): "latest" | "nightly" { + return cliReleaseChannelOf(version) === "nightly" ? "nightly" : "latest"; +} + +/** Whether a track switch may install `found` after its database check cleared `checked`: any build + * on the switch's track at least as new, since later releases only add migrations. */ +export function acceptsTrackSwitchRelease( + found: string, + channel: "latest" | "nightly", + checked: string, +): boolean { + return ( + cliReleaseChannelOf(found) === (channel === "nightly" ? "nightly" : "stable") && + compareSemverVersions(found, checked) >= 0 + ); +} + /** Page until the channel matches; nightlies can push preview/stable releases past the first page. */ export function cliReleaseIndexPageUrl(page: number): string { return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`; @@ -89,3 +120,14 @@ export function newestCliReleaseVersion( } return undefined; } + +const NpmReleaseMigrationManifest = Schema.Struct({ + t3code: Schema.Struct({ databaseMigration: Schema.Int }), +}); +const decodeNpmReleaseMigrationManifest = Schema.decodeUnknownOption(NpmReleaseMigrationManifest); + +/** Highest database migration a release's server knows, from its npm `t3` manifest. */ +export function npmReleaseDatabaseMigration(manifest: unknown): number | undefined { + return Option.getOrUndefined(decodeNpmReleaseMigrationManifest(manifest))?.t3code + .databaseMigration; +} diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 97846939d31cc..f9a43b6123a1e 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -24,6 +24,24 @@ import { const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { + it("replaces and clears the fallback without carrying model options", () => { + const fallbackModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "new" }; + const settings = applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + fallbackModelSelection: { + ...fallbackModelSelection, + model: "old", + options: [{ id: "effort", value: "high" }], + }, + }, + { fallbackModelSelection }, + ); + expect(settings.fallbackModelSelection).toEqual(fallbackModelSelection); + expect( + applyServerSettingsPatch(settings, { fallbackModelSelection: null }).fallbackModelSelection, + ).toBeNull(); + }); it("ignores a configured non-default instance at machine scope, matching no fallback", () => { const configured = ProviderInstanceId.make("codex_work"); const providerInstances = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 0c151eb5025af..e7c856070658a 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -92,13 +92,15 @@ export function fallbackTextGenerationModelSelection( ) { return threadDefault; } - return { - instanceId, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL, - }; + return { instanceId, model: defaultTextGenerationModel(driver) }; +} + +export function defaultTextGenerationModel(driver: ProviderDriverKind): string { + return ( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL + ); } export function resolveSourceControlWriterModelSelection( @@ -538,6 +540,9 @@ export function applyServerSettingsPatch( ...(patch.defaultModelSelection !== undefined ? { defaultModelSelection: patch.defaultModelSelection } : {}), + ...(patch.fallbackModelSelection !== undefined + ? { fallbackModelSelection: patch.fallbackModelSelection } + : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 503521c087d72..5da7bc2cc02db 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderDriverKind, ProviderInstanceId, @@ -17,9 +18,11 @@ import { collectLimitNotices, collectLimitPools, elapsedShare, + exhaustedUsageWindow, paceOf, paceRemainingPercent, usesChatGptSharing, + resolveNewThreadModelSelection, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -50,6 +53,106 @@ function provider(overrides: Partial): ServerProvider { }; } +describe("new thread model fallback", () => { + const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const fallback = { instanceId: ProviderInstanceId.make("claude"), model: "claude-fable-5-1" }; + const limited = provider({ + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...window, usedPercent: 100 }], + }, + }); + const available = provider({ + instanceId: fallback.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + }); + + const defaults = { + defaultSelection: selection, + fallbackSelection: fallback, + providers: [limited, available], + now, + }; + it.each([ + ["exhausted default", {}, fallback], + ["explicit selection", { explicitSelection: selection }, selection], + ["missing default", { defaultSelection: null }, null], + ["no fallback", { fallbackSelection: null }, selection], + ["expired window", { now: Date.parse("2026-09-04T00:00:00Z") }, selection], + [ + "environment disabled", + { + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [fallback.instanceId]: { driver: available.driver, enabled: false, config: {} }, + }, + }, + }, + selection, + ], + ] as const)("resolves %s", (_name, overrides, expected) => { + expect(resolveNewThreadModelSelection({ ...defaults, ...overrides }).selection).toEqual( + expected, + ); + }); + it.each([ + { enabled: false }, + { installed: false }, + { auth: { status: "unauthenticated" as const } }, + { availability: "unavailable" as const }, + { usageLimits: limited.usageLimits }, + ])("rejects an unusable fallback: %j", (overrides) => { + expect( + resolveNewThreadModelSelection({ + ...defaults, + providers: [limited, { ...available, ...overrides }], + }).selection, + ).toEqual(selection); + }); + it.each([undefined, "invalid"])("treats an unknown reset as exhausted: %s", (resetsAt) => { + expect( + exhaustedUsageWindow( + provider({ + usageLimits: { + checkedAt: limited.checkedAt, + windows: [{ ...window, usedPercent: 100, resetsAt }], + }, + }), + now, + ), + ).toBeDefined(); + }); + it.each([ + ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false], + ["cursor", ["autoPercentUsed"], "composer-2.5", false], + ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true], + ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false], + ["antigravity", ["3p-weekly"], "claude-opus-5-5", true], + ["antigravity", ["gemini-weekly"], "antigravity-default", true], + ["opencode", ["go_weekly"], "opencode/big-pickle", false], + ["opencode", ["go_weekly"], "opencode-go/big-pickle", true], + ] as const)("matches %s pools %s to %s", (driver, ids, model, usesFallback) => { + const defaultSelection = { ...selection, model }; + expect( + resolveNewThreadModelSelection({ + ...defaults, + defaultSelection, + providers: [ + provider({ + driver: ProviderDriverKind.make(driver), + usageLimits: { + checkedAt: limited.checkedAt, + windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), + }, + }), + available, + ], + }).selection, + ).toEqual(usesFallback ? fallback : defaultSelection); + }); +}); + describe("pace", () => { it("places the clock three fifths through a five-hour window with two hours left", () => { expect(elapsedShare(window, now)).toBeCloseTo(0.6); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 01e2ad59a332d..3dca207a2efb7 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1,5 +1,9 @@ import { type EnvironmentId, + type ModelSelection, + type ServerSettings, + type ProjectId, + resolveProjectProviderInstanceEnabled, type UsageLimitsReport, type ProviderInstanceId, type ProviderConsumeResetCreditInput, @@ -17,6 +21,85 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export function exhaustedUsageWindow( + provider: ServerProvider | undefined, + now: number, + model?: string, +) { + const exhausted = (provider?.usageLimits?.windows ?? []).filter( + (window) => + (window.usedPercent ?? 0) >= 100 && + (!window.resetsAt || + !Number.isFinite(Date.parse(window.resetsAt)) || + Date.parse(window.resetsAt) > now), + ); + if (model === undefined) return exhausted[0]; + switch (provider?.driver) { + case "cursor": { + const api = exhausted.find((window) => window.id === "apiPercentUsed"); + const auto = exhausted.find((window) => window.id === "autoPercentUsed"); + return /^(auto|default|grok|composer)/.test(model) ? auto && api : api; + } + case "antigravity": { + const family = model.match(/^(gemini|claude|gpt)-/)?.[1]; + return exhausted.find((window) => { + const pool = window.id.match(/^(gemini|claude|gpt|3p)-/)?.[1]; + return ( + pool === undefined || + family === undefined || + pool === family || + (pool === "3p" && (family === "claude" || family === "gpt")) + ); + }); + } + case "opencode": + return exhausted.find( + (window) => !window.id.startsWith("go_") || model.startsWith("opencode-go/"), + ); + default: + return exhausted[0]; + } +} + +export function resolveNewThreadModelSelection(input: { + readonly defaultSelection: ModelSelection | null; + readonly explicitSelection?: ModelSelection | null; + readonly fallbackSelection: ModelSelection | null; + readonly providers: readonly ServerProvider[]; + readonly now: number; + readonly settings?: ServerSettings; + readonly projectId?: ProjectId | null; +}) { + const selection = input.explicitSelection ?? input.defaultSelection; + const unchanged = { selection, fallback: null }; + if (input.explicitSelection || !selection || !input.fallbackSelection) return unchanged; + const provider = input.providers.find((entry) => entry.instanceId === selection.instanceId); + const window = exhaustedUsageWindow(provider, input.now, selection.model); + const fallback = input.providers.find( + (entry) => entry.instanceId === input.fallbackSelection?.instanceId, + ); + if ( + !provider || + !window || + !fallback?.enabled || + !fallback.installed || + fallback.auth.status === "unauthenticated" || + !isProviderAvailable(fallback) || + exhaustedUsageWindow(fallback, input.now, input.fallbackSelection.model) || + (input.settings && + !resolveProjectProviderInstanceEnabled( + input.settings, + input.projectId ?? null, + fallback.instanceId, + )) + ) + return unchanged; + return { + selection: input.fallbackSelection, + fallback: { provider, resetsAt: window.resetsAt ?? null }, + }; +} + export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; export const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; /** A ChatGPT Business admin caps plan use in other apps; only the admin can raise it. */ diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index b300f14afb0cb..46fc8fd48b0dc 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -182,6 +182,7 @@ export interface ProviderChangeRequestStat { } export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + readonly maintainerCanModify?: boolean; readonly headSha?: string | null; readonly body: string; readonly changedFiles: number; @@ -462,6 +463,7 @@ export interface PullRequestProviderApi { /** Requires edit capability and at least one field. */ readonly updateChangeRequest?: ( input: ProviderRepositoryRef & { + readonly maintainerCanModify?: boolean | undefined; readonly number: number; readonly title?: string | undefined; readonly body?: string | undefined; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 3fd74e08130ec..a4b5eb8da4c22 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -68,6 +68,7 @@ const coreResponse = (pullRequest: Readonly> = {}) => ({ mergedAt: null, closedAt: null, isCrossRepository: false, + maintainerCanModify: true, headRepositoryOwner: null, headRepository: null, headRef: null, @@ -710,6 +711,25 @@ it.effect( ); layer("GitHubPullRequestApi.layer", (it) => { + it.effect("turns maintainer edits off without rewriting the pull request's text", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + output( + encodeJson( + call.kind === "graphql" && matches(call, NODE_ID_QUERY) ? nodeIdAnswer("PR_7") : {}, + ), + ), + ), + ); + const api = yield* GitHubPullRequestApi.GitHubPullRequestApi; + yield* api.updatePullRequest({ ...pullRequestInput, maintainerCanModify: false }); + expect(variablesOf("mutation UpdatePullRequest")).toEqual([ + { pullRequestId: "PR_7", maintainerCanModify: false }, + ]); + }), + ); + it.effect.each(["mixed", "merges", "unavailable"] as const)( "reads stats only for displayed non-merge commits (%s)", (kind) => diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 4b7014a890744..16a7a1df18cdb 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -705,6 +705,7 @@ export class GitHubPullRequestApi extends Context.Service< }) => Effect.Effect; readonly updatePullRequest: (input: { + readonly maintainerCanModify?: boolean | undefined; readonly cwd: string; readonly repository: string; readonly host: string; @@ -2740,6 +2741,9 @@ export const make = Effect.gen(function* () { client .UpdatePullRequest({ pullRequestId, + ...(input.maintainerCanModify === undefined + ? {} + : { maintainerCanModify: input.maintainerCanModify }), ...(input.title === undefined ? {} : { title: input.title }), ...(input.body === undefined ? {} : { body: input.body }), }) diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index 81623592066e5..dc8ca69770e46 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -16,6 +16,8 @@ import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullReque import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; const coreFields = { + maintainerCanModify: true, + canEditMaintainerEdits: false, reviewRequestTeams: [], checksTruncated: false, comparison: { behindBy: 0, viewerCanUpdate: true }, @@ -205,6 +207,7 @@ describe("gitHubViewerPermissions", () => { }); expect(detail.viewerPermissions).toEqual({ + maintainerEdits: false, actions: ["ready", "draft", "close", "reopen"], comment: true, resolve: false, diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index f90941bf882ef..4f3490eec97b0 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -49,7 +49,7 @@ const CAPABILITIES: PullRequestCapabilities = { verdicts: ["comment", "approve", "request-changes"], }, reviewers: { request: true, listCandidates: true }, - edit: { changeRequest: true, comment: true }, + edit: { changeRequest: true, comment: true, maintainerEdits: true }, stacks: true, stackActions: true, labels: true, @@ -453,10 +453,13 @@ export const make = Effect.gen(function* () { ...pullRequest.reviewRequestTeams, ], mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions({ - ...pullRequest.viewerAccess, - canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, - }), + viewerPermissions: { + ...gitHubViewerPermissions({ + ...pullRequest.viewerAccess, + canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, + }), + maintainerEdits: pullRequest.canEditMaintainerEdits, + }, baseComparison: pullRequest.comparison === null || pullRequest.comparison.behindBy === null ? "unknown" @@ -666,6 +669,9 @@ export const make = Effect.gen(function* () { updateChangeRequest: (input) => cli .updatePullRequest({ + ...(input.maintainerCanModify === undefined + ? {} + : { maintainerCanModify: input.maintainerCanModify }), cwd: input.cwd, repository: input.repository, host: input.host, diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 4e6321aa3c7ac..622b48db3b85d 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -362,6 +362,7 @@ describe("pull request detail decoding", () => { baseRefName: "main", headRefOid: "abc123", isCrossRepository: false, + maintainerCanModify: true, headRepositoryOwner: null, headRepository: null, headRef: null, @@ -394,6 +395,30 @@ describe("pull request detail decoding", () => { pullRequest?: Record, ) => expectSuccess(decodePullRequestCoreJson(coreJson(rollup, pullRequest))); + it.each([ + [{}, true], + [{ viewerDidAuthor: false }, false], + [{ isCrossRepository: false }, false], + [{ state: "CLOSED" }, false], + [{ headRepositoryOwner: { __typename: "Organization", login: "team" } }, false], + [{ headRepository: null }, false], + ])( + "reads maintainer edits and lets only the author of an open personal fork change them: %j", + (overrides, allowed) => { + const detail = detailOf([], { + maintainerCanModify: false, + isCrossRepository: true, + viewerDidAuthor: true, + headRepositoryOwner: { __typename: "User", login: "octocat" }, + headRepository: { nameWithOwner: "octocat/t3code", viewerPermission: "ADMIN" }, + headRef: { id: "REF_7" }, + ...overrides, + }); + expect(detail.maintainerCanModify).toBe(false); + expect(detail.canEditMaintainerEdits).toBe(allowed); + }, + ); + const detailJson = JSON.stringify({ comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], reviews: [ diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 965855a4350ac..e7c05a4aa8445 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -978,6 +978,8 @@ export function toPullRequestWatchFingerprint( } export interface GitHubPullRequestCore extends GitHubPullRequestDetail { + readonly maintainerCanModify: boolean; + readonly canEditMaintainerEdits: boolean; readonly reviewRequestTeams: ReadonlyArray; readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; @@ -1023,6 +1025,14 @@ export function toPullRequestCore(result: PullRequestCore.Result): GitHubPullReq statusCheckRollup: toCheckContexts(nodesOf(contexts)), }), headRepositoryNameWithOwner: trimmed(pr.headRepository?.nameWithOwner), + maintainerCanModify: pr.maintainerCanModify, + canEditMaintainerEdits: + pr.state === "OPEN" && + pr.isCrossRepository && + pr.viewerDidAuthor && + pr.headRepositoryOwner?.__typename === "User" && + pr.headRepository !== null && + pr.headRef !== null, viewerAccess: { canWrite: toCanWrite(repository.viewerPermission), canDeleteBranch: toCanWrite(pr.headRepository?.viewerPermission) && pr.headRef !== null, diff --git a/packages/source-control-github/src/server/pullRequestMutations.graphql b/packages/source-control-github/src/server/pullRequestMutations.graphql index 4ba7854507af7..3e2d5b3133ad5 100644 --- a/packages/source-control-github/src/server/pullRequestMutations.graphql +++ b/packages/source-control-github/src/server/pullRequestMutations.graphql @@ -41,11 +41,23 @@ mutation UnresolveReviewThread($threadId: ID!) { } } -# Rewrites the pull request's own words. Both are nullable so that one document serves a change to -# the title, to the description, or to the two together: a variable the request does not send puts -# no entry in the input at all, which leaves that field as it was rather than clearing it. -mutation UpdatePullRequest($pullRequestId: ID!, $title: String, $body: String) { - updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) { +# Rewrites the pull request's own words or its maintainer-edit setting. Every field is nullable so +# that one document serves any combination: a variable the request does not send puts no entry in +# the input at all, which leaves that field as it was rather than clearing it. +mutation UpdatePullRequest( + $pullRequestId: ID! + $title: String + $body: String + $maintainerCanModify: Boolean +) { + updatePullRequest( + input: { + pullRequestId: $pullRequestId + title: $title + body: $body + maintainerCanModify: $maintainerCanModify + } + ) { pullRequest { id } diff --git a/packages/source-control-github/src/server/pullRequestMutations.graphql.ts b/packages/source-control-github/src/server/pullRequestMutations.graphql.ts index 05a67b81d4e5c..d11b2b1e69fcf 100644 --- a/packages/source-control-github/src/server/pullRequestMutations.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestMutations.graphql.ts @@ -205,17 +205,19 @@ export const UpdatePullRequest = GraphQL.mutation("UpdatePullRequest", { variables: [ { name: "pullRequestId", body: "ID!" }, { name: "title", body: "String" }, - { name: "body", body: "String" } + { name: "body", body: "String" }, + { name: "maintainerCanModify", body: "Boolean" } ], selections: [ - { name: "updatePullRequest", body: ["(input:{pullRequestId:$", "pullRequestId", " title:$", "title", " body:$", "body", "}){pullRequest{id}}"] } + { name: "updatePullRequest", body: ["(input:{pullRequestId:$", "pullRequestId", " title:$", "title", " body:$", "body", " maintainerCanModify:$", "maintainerCanModify", "}){pullRequest{id}}"] } ], fragments: [] }, variables: { pullRequestId: Shared.ID, title: Schema.optional(Schema.NullOr(Schema.String)), - body: Schema.optional(Schema.NullOr(Schema.String)) + body: Schema.optional(Schema.NullOr(Schema.String)), + maintainerCanModify: Schema.optional(Schema.NullOr(Schema.Boolean)) }, result: Schema.Struct({ /** Update a pull request */ diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index d5a95256fc1a3..78e5efe0e4558 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -87,6 +87,7 @@ query PullRequestCore( baseRefName headRefOid isCrossRepository + maintainerCanModify headRepositoryOwner { login } diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index e261ec0240a73..5bc09fa4e2ce4 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -395,6 +395,8 @@ export const PullRequestCoreRepositoryPullRequestPullRequest = Schema.Struct({ headRefOid: Shared.GitObjectID, /** The head and base repositories are different. */ isCrossRepository: Schema.Boolean, + /** Indicates whether maintainers can modify the pull request. */ + maintainerCanModify: Schema.Boolean, /** The owner of the repository associated with this pull request's head Ref. */ headRepositoryOwner: Schema.NullOr(RepositoryOwner), /** The repository associated with this pull request's head Ref. */ @@ -457,7 +459,7 @@ export const PullRequestCore = GraphQL.query("PullRequestCore", { { name: "requestedReviewers", body: "Int!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft mergeable mergeStateStatus reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:$", "requestedReviewers", "){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage}}}}}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft mergeable mergeStateStatus reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository maintainerCanModify headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:$", "requestedReviewers", "){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage}}}}}}}}"] } ], fragments: [ { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } diff --git a/patches/@expo__ui@58.0.20.patch b/patches/@expo__ui@58.0.20.patch index 2546d83b80428..859ee062b9fdf 100644 --- a/patches/@expo__ui@58.0.20.patch +++ b/patches/@expo__ui@58.0.20.patch @@ -1,8 +1,49 @@ diff --git a/android/src/main/java/expo/modules/ui/ExpoUIModule.kt b/android/src/main/java/expo/modules/ui/ExpoUIModule.kt -index d58b54f..1920b1a 100644 +index d58b54fc30ba0f9e5bc35b505ae598161a59b9b1..2da4df803a918d034921d41d49d92121e6303f82 100644 --- a/android/src/main/java/expo/modules/ui/ExpoUIModule.kt +++ b/android/src/main/java/expo/modules/ui/ExpoUIModule.kt -@@ -533,6 +533,10 @@ class ExpoUIModule : Module() { +@@ -29,6 +29,13 @@ import expo.modules.ui.button.IconButtonContent + import expo.modules.ui.button.FilledIconButtonContent + import expo.modules.ui.button.FilledTonalIconButtonContent + import expo.modules.ui.button.OutlinedIconButtonContent ++import expo.modules.ui.button.SplitButtonCheckedChangeEvent ++import expo.modules.ui.button.SplitButtonLayoutContent ++import expo.modules.ui.button.SplitButtonLayoutProps ++import expo.modules.ui.button.SplitButtonLeadingButtonContent ++import expo.modules.ui.button.SplitButtonLeadingButtonProps ++import expo.modules.ui.button.SplitButtonTrailingButtonContent ++import expo.modules.ui.button.SplitButtonTrailingButtonProps + import expo.modules.ui.graphics.ImageLoader + import expo.modules.ui.icon.IconView + import expo.modules.ui.image.ImageView +@@ -294,6 +301,26 @@ class ExpoUIModule : Module() { + } + } + ++ ExpoUIView("SplitButtonLayoutView") { ++ Content { props -> SplitButtonLayoutContent(props) } ++ } ++ ++ ExpoUIView("SplitButtonLeadingButtonView") { ++ val onButtonPressed by Event() ++ ++ Content { props -> ++ SplitButtonLeadingButtonContent(props) { onButtonPressed(Unit) } ++ } ++ } ++ ++ ExpoUIView("SplitButtonTrailingButtonView") { ++ val onCheckedChange by Event() ++ ++ Content { props -> ++ SplitButtonTrailingButtonContent(props) { onCheckedChange(it) } ++ } ++ } ++ + ExpoUIView("FilledTonalButton") { + val onButtonPressed by Event() + +@@ -533,6 +560,10 @@ class ExpoUIModule : Module() { } } @@ -14,7 +55,7 @@ index d58b54f..1920b1a 100644 Content { props -> HorizontalFloatingToolbarContent(props) diff --git a/android/src/main/java/expo/modules/ui/HostView.kt b/android/src/main/java/expo/modules/ui/HostView.kt -index 4887034..6eaf039 100644 +index 4887034bf7e3e6b5d8d2c1ae25932f5a24f9435d..6eaf039a14820ddaf635a1538c2a9a644cf704fb 100644 --- a/android/src/main/java/expo/modules/ui/HostView.kt +++ b/android/src/main/java/expo/modules/ui/HostView.kt @@ -69,6 +69,7 @@ internal enum class ExpoLayoutDirection(val value: String) : Enumerable { @@ -89,7 +130,7 @@ index 4887034..6eaf039 100644 // Material3's `MaterialTheme` does not provide `LocalContentColor` — only `Surface` does — so diff --git a/android/src/main/java/expo/modules/ui/TopAppBarView.kt b/android/src/main/java/expo/modules/ui/TopAppBarView.kt new file mode 100644 -index 0000000..4ed0132 +index 0000000000000000000000000000000000000000..4ed0132d57fc7aff08ecb7ee84fb8fa48942931b --- /dev/null +++ b/android/src/main/java/expo/modules/ui/TopAppBarView.kt @@ -0,0 +1,47 @@ @@ -140,8 +181,109 @@ index 0000000..4ed0132 + modifier = ModifierRegistry.applyModifiers(props.modifiers, appContext, composableScope, globalEventDispatcher) + ) +} +diff --git a/android/src/main/java/expo/modules/ui/button/SplitButton.kt b/android/src/main/java/expo/modules/ui/button/SplitButton.kt +new file mode 100644 +index 0000000000000000000000000000000000000000..10df003066fb0703645c4306bd557590b2cae9aa +--- /dev/null ++++ b/android/src/main/java/expo/modules/ui/button/SplitButton.kt +@@ -0,0 +1,95 @@ ++@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) ++ ++package expo.modules.ui.button ++ ++import androidx.compose.material3.ButtonDefaults ++import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi ++import androidx.compose.material3.SplitButtonDefaults ++import androidx.compose.material3.SplitButtonLayout ++import androidx.compose.runtime.Composable ++import expo.modules.kotlin.records.Field ++import expo.modules.kotlin.records.Record ++import expo.modules.kotlin.types.OptimizedRecord ++import expo.modules.kotlin.views.ComposeProps ++import expo.modules.kotlin.views.FunctionalComposableScope ++import expo.modules.kotlin.views.OptimizedComposeProps ++import expo.modules.ui.ModifierList ++import expo.modules.ui.ModifierRegistry ++import expo.modules.ui.UIComposableScope ++import expo.modules.ui.compose ++import expo.modules.ui.isSlotWithName ++ ++@OptimizedComposeProps ++data class SplitButtonLayoutProps( ++ val modifiers: ModifierList = emptyList() ++) : ComposeProps ++ ++@OptimizedComposeProps ++data class SplitButtonLeadingButtonProps( ++ val colors: ButtonColors = ButtonColors(), ++ val enabled: Boolean = true, ++ val modifiers: ModifierList = emptyList() ++) : ComposeProps ++ ++@OptimizedComposeProps ++data class SplitButtonTrailingButtonProps( ++ val checked: Boolean = false, ++ val colors: ButtonColors = ButtonColors(), ++ val enabled: Boolean = true, ++ val modifiers: ModifierList = emptyList() ++) : ComposeProps ++ ++@OptimizedRecord ++data class SplitButtonCheckedChangeEvent( ++ @Field val checked: Boolean = false ++) : Record ++ ++@Composable ++fun FunctionalComposableScope.SplitButtonLayoutContent(props: SplitButtonLayoutProps) { ++ SplitButtonLayout( ++ leadingButton = { Children(UIComposableScope(), filter = { isSlotWithName(it, "leadingButton") }) }, ++ trailingButton = { Children(UIComposableScope(), filter = { isSlotWithName(it, "trailingButton") }) }, ++ modifier = ModifierRegistry.applyModifiers(props.modifiers, appContext, composableScope, globalEventDispatcher) ++ ) ++} ++ ++@Composable ++fun FunctionalComposableScope.SplitButtonLeadingButtonContent( ++ props: SplitButtonLeadingButtonProps, ++ onClick: () -> Unit ++) { ++ SplitButtonDefaults.LeadingButton( ++ onClick = onClick, ++ enabled = props.enabled, ++ colors = ButtonDefaults.buttonColors( ++ containerColor = props.colors.containerColor.compose, ++ contentColor = props.colors.contentColor.compose, ++ disabledContainerColor = props.colors.disabledContainerColor.compose, ++ disabledContentColor = props.colors.disabledContentColor.compose ++ ), ++ modifier = ModifierRegistry.applyModifiers(props.modifiers, appContext, composableScope, globalEventDispatcher) ++ ) { ++ Children(UIComposableScope(rowScope = this)) ++ } ++} ++ ++@Composable ++fun FunctionalComposableScope.SplitButtonTrailingButtonContent( ++ props: SplitButtonTrailingButtonProps, ++ onCheckedChange: (SplitButtonCheckedChangeEvent) -> Unit ++) { ++ SplitButtonDefaults.TrailingButton( ++ checked = props.checked, ++ onCheckedChange = { onCheckedChange(SplitButtonCheckedChangeEvent(it)) }, ++ enabled = props.enabled, ++ colors = ButtonDefaults.buttonColors( ++ containerColor = props.colors.containerColor.compose, ++ contentColor = props.colors.contentColor.compose, ++ disabledContainerColor = props.colors.disabledContainerColor.compose, ++ disabledContentColor = props.colors.disabledContentColor.compose ++ ), ++ modifier = ModifierRegistry.applyModifiers(props.modifiers, appContext, composableScope, globalEventDispatcher) ++ ) { ++ Children(UIComposableScope(rowScope = this)) ++ } ++} diff --git a/build/jetpack-compose/Host/index.d.ts b/build/jetpack-compose/Host/index.d.ts -index b586b65..e32f6e0 100644 +index b586b653a991ce8840b9994a6fe1e6af75480ece..e32f6e093fde22e9cfc9d7ff612cddb59e036945 100644 --- a/build/jetpack-compose/Host/index.d.ts +++ b/build/jetpack-compose/Host/index.d.ts @@ -1,6 +1,7 @@ @@ -161,7 +303,7 @@ index b586b65..e32f6e0 100644 * The layout direction for the content. * Defaults to the current locale direction from I18nManager. diff --git a/build/jetpack-compose/Host/index.js b/build/jetpack-compose/Host/index.js -index 3985656..2b4a4f9 100644 +index 39856563a8cf1119f3a72e37fd35d48d245041bf..2b4a4f946f2ff04a5563c4cd8a29f28b0c5cc08b 100644 --- a/build/jetpack-compose/Host/index.js +++ b/build/jetpack-compose/Host/index.js @@ -8,16 +8,17 @@ import { useMergeRefs } from '../../utils/useMergeRefs'; @@ -194,9 +336,141 @@ index 3985656..2b4a4f9 100644 onLayoutContent: onLayoutContent, layoutDirection: layoutDirection ?? (I18nManager.getConstants().isRTL ? 'rightToLeft' : 'leftToRight'), ref: mergedRef, +diff --git a/build/jetpack-compose/SplitButton/index.d.ts b/build/jetpack-compose/SplitButton/index.d.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..4c228bd16b10c8d08d96c96bb143e07769d06aa6 +--- /dev/null ++++ b/build/jetpack-compose/SplitButton/index.d.ts +@@ -0,0 +1,73 @@ ++import type { ReactNode } from 'react'; ++import type { ModifierConfig } from '../../types'; ++import type { ButtonColors } from '../Button'; ++export interface SplitButtonLayoutProps { ++ /** ++ * A `SplitButtonLayout.LeadingButton` and a `SplitButtonLayout.TrailingButton`. ++ */ ++ children: ReactNode; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++} ++export interface SplitButtonLeadingButtonProps { ++ /** ++ * Callback that is called when the leading button is clicked. ++ */ ++ onClick?: () => void; ++ /** ++ * Whether the button is enabled for user interaction. ++ * @default true ++ */ ++ enabled?: boolean; ++ /** ++ * Colors for button elements. ++ */ ++ colors?: ButtonColors; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++ /** ++ * Content to display inside the button. ++ */ ++ children: ReactNode; ++} ++export interface SplitButtonTrailingButtonProps { ++ /** ++ * Whether the trailing button is checked, usually while its menu is expanded. ++ */ ++ checked: boolean; ++ /** ++ * Callback that is called when the checked state changes. ++ */ ++ onCheckedChange?: (checked: boolean) => void; ++ /** ++ * Whether the button is enabled for user interaction. ++ * @default true ++ */ ++ enabled?: boolean; ++ /** ++ * Colors for button elements. ++ */ ++ colors?: ButtonColors; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++ /** ++ * Content to display inside the button, usually a chevron icon. ++ */ ++ children: ReactNode; ++} ++/** ++ * A Material 3 split button: a leading action button and a trailing toggle button, ++ * usually anchoring a `DropdownMenu` of related actions. ++ */ ++declare function SplitButtonLayout(props: SplitButtonLayoutProps): import("react/jsx-runtime").JSX.Element; ++declare namespace SplitButtonLayout { ++ var LeadingButton: (props: SplitButtonLeadingButtonProps) => import("react/jsx-runtime").JSX.Element; ++ var TrailingButton: (props: SplitButtonTrailingButtonProps) => import("react/jsx-runtime").JSX.Element; ++} ++export { SplitButtonLayout }; +diff --git a/build/jetpack-compose/SplitButton/index.js b/build/jetpack-compose/SplitButton/index.js +new file mode 100644 +index 0000000000000000000000000000000000000000..0e723b1e28d30ba0410437eaa5a1a9a0f50e6ad4 +--- /dev/null ++++ b/build/jetpack-compose/SplitButton/index.js +@@ -0,0 +1,47 @@ ++import { jsx as _jsx } from "react/jsx-runtime"; ++import { requireNativeView } from 'expo'; ++import { createViewModifierEventListener } from '../modifiers/utils'; ++const SplitButtonLayoutNativeView = requireNativeView('ExpoUI', 'SplitButtonLayoutView'); ++const LeadingButtonNativeView = requireNativeView('ExpoUI', 'SplitButtonLeadingButtonView'); ++const TrailingButtonNativeView = requireNativeView('ExpoUI', 'SplitButtonTrailingButtonView'); ++const SlotNativeView = requireNativeView('ExpoUI', 'SlotView'); ++function LeadingButton(props) { ++ const { onClick, modifiers, ...restProps } = props; ++ return /*#__PURE__*/ _jsx(SlotNativeView, { ++ slotName: "leadingButton", ++ children: /*#__PURE__*/ _jsx(LeadingButtonNativeView, { ++ modifiers: modifiers, ++ ...modifiers ? createViewModifierEventListener(modifiers) : undefined, ++ ...restProps, ++ enabled: props.enabled ?? true, ++ onButtonPressed: onClick ? ()=>onClick() : undefined ++ }) ++ }); ++} ++function TrailingButton(props) { ++ const { onCheckedChange, modifiers, ...restProps } = props; ++ return /*#__PURE__*/ _jsx(SlotNativeView, { ++ slotName: "trailingButton", ++ children: /*#__PURE__*/ _jsx(TrailingButtonNativeView, { ++ modifiers: modifiers, ++ ...modifiers ? createViewModifierEventListener(modifiers) : undefined, ++ ...restProps, ++ enabled: props.enabled ?? true, ++ onCheckedChange: onCheckedChange ? ({ nativeEvent: { checked } })=>onCheckedChange(checked) : undefined ++ }) ++ }); ++} ++/** ++ * A Material 3 split button: a leading action button and a trailing toggle button, ++ * usually anchoring a `DropdownMenu` of related actions. ++ */ function SplitButtonLayout(props) { ++ const { modifiers, ...restProps } = props; ++ return /*#__PURE__*/ _jsx(SplitButtonLayoutNativeView, { ++ modifiers: modifiers, ++ ...modifiers ? createViewModifierEventListener(modifiers) : undefined, ++ ...restProps ++ }); ++} ++SplitButtonLayout.LeadingButton = LeadingButton; ++SplitButtonLayout.TrailingButton = TrailingButton; ++export { SplitButtonLayout }; diff --git a/build/jetpack-compose/TopAppBar/index.d.ts b/build/jetpack-compose/TopAppBar/index.d.ts new file mode 100644 -index 0000000..63afa28 +index 0000000000000000000000000000000000000000..63afa28d56a9b0260de16d32e08bd7405e2c517d --- /dev/null +++ b/build/jetpack-compose/TopAppBar/index.d.ts @@ -0,0 +1,15 @@ @@ -217,7 +491,7 @@ index 0000000..63afa28 +} diff --git a/build/jetpack-compose/TopAppBar/index.js b/build/jetpack-compose/TopAppBar/index.js new file mode 100644 -index 0000000..c6557fe +index 0000000000000000000000000000000000000000..c6557fe6cb559c7d922fc02ce25fa2bf080a50d6 --- /dev/null +++ b/build/jetpack-compose/TopAppBar/index.js @@ -0,0 +1,11 @@ @@ -233,7 +507,7 @@ index 0000000..c6557fe +TopAppBar.NavigationIcon = function NavigationIcon({ children }) { return jsx(Slot, { style: { position: 'absolute' }, slotName: 'navigationIcon', children }); }; +TopAppBar.Actions = function Actions({ children }) { return jsx(Slot, { style: { position: 'absolute' }, slotName: 'actions', children }); }; diff --git a/build/jetpack-compose/index.d.ts b/build/jetpack-compose/index.d.ts -index fa70b35..436a8b7 100644 +index fa70b3574c4c40753b683a98c053caaf63e0385b..96fbbbeaba8396cc9c5cf255c61991bbb2afa899 100644 --- a/build/jetpack-compose/index.d.ts +++ b/build/jetpack-compose/index.d.ts @@ -1,3 +1,4 @@ @@ -241,8 +515,16 @@ index fa70b35..436a8b7 100644 import './MaterialSymbolsAssetsTransformer.fx'; export * from './AlertDialog'; export * from './Badge'; +@@ -21,6 +22,7 @@ export * from './ListItem'; + export * from './RNHostView'; + export * from './DatePicker'; + export * from './SegmentedButton'; ++export * from './SplitButton'; + export * from './Progress'; + export * from './Slider'; + export * from './Spacer'; diff --git a/build/jetpack-compose/index.js b/build/jetpack-compose/index.js -index 2457ed1..fdc8eb1 100644 +index 2457ed1be68b4295442f24a6175fa347cbf80cdb..f394321920fbf1ebb6c7c68bb46690538a05aa0d 100644 --- a/build/jetpack-compose/index.js +++ b/build/jetpack-compose/index.js @@ -1,3 +1,4 @@ @@ -250,8 +532,16 @@ index 2457ed1..fdc8eb1 100644 import './MaterialSymbolsAssetsTransformer.fx'; export * from './AlertDialog'; export * from './Badge'; +@@ -21,6 +22,7 @@ export * from './ListItem'; + export * from './RNHostView'; + export * from './DatePicker'; + export * from './SegmentedButton'; ++export * from './SplitButton'; + export * from './Progress'; + export * from './Slider'; + export * from './Spacer'; diff --git a/src/jetpack-compose/Host/index.tsx b/src/jetpack-compose/Host/index.tsx -index e994f6e..d0ed70a 100644 +index e994f6e1fbad28414207482deeceb542a7b95746..d0ed70a3b9ab0ce5e5c1fd9106c0759f181b6e96 100644 --- a/src/jetpack-compose/Host/index.tsx +++ b/src/jetpack-compose/Host/index.tsx @@ -12,7 +12,7 @@ import { @@ -300,9 +590,157 @@ index e994f6e..d0ed70a 100644 onLayoutContent={onLayoutContent} layoutDirection={ layoutDirection ?? (I18nManager.getConstants().isRTL ? 'rightToLeft' : 'leftToRight') +diff --git a/src/jetpack-compose/SplitButton/index.tsx b/src/jetpack-compose/SplitButton/index.tsx +new file mode 100644 +index 0000000000000000000000000000000000000000..05cba2bfa15b553f74ccacac0b889fae73b13007 +--- /dev/null ++++ b/src/jetpack-compose/SplitButton/index.tsx +@@ -0,0 +1,142 @@ ++import { requireNativeView } from 'expo'; ++import type { ReactNode } from 'react'; ++ ++import type { ModifierConfig, ViewEvent } from '../../types'; ++import type { ButtonColors } from '../Button'; ++import { createViewModifierEventListener } from '../modifiers/utils'; ++ ++export interface SplitButtonLayoutProps { ++ /** ++ * A `SplitButtonLayout.LeadingButton` and a `SplitButtonLayout.TrailingButton`. ++ */ ++ children: ReactNode; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++} ++ ++export interface SplitButtonLeadingButtonProps { ++ /** ++ * Callback that is called when the leading button is clicked. ++ */ ++ onClick?: () => void; ++ /** ++ * Whether the button is enabled for user interaction. ++ * @default true ++ */ ++ enabled?: boolean; ++ /** ++ * Colors for button elements. ++ */ ++ colors?: ButtonColors; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++ /** ++ * Content to display inside the button. ++ */ ++ children: ReactNode; ++} ++ ++export interface SplitButtonTrailingButtonProps { ++ /** ++ * Whether the trailing button is checked, usually while its menu is expanded. ++ */ ++ checked: boolean; ++ /** ++ * Callback that is called when the checked state changes. ++ */ ++ onCheckedChange?: (checked: boolean) => void; ++ /** ++ * Whether the button is enabled for user interaction. ++ * @default true ++ */ ++ enabled?: boolean; ++ /** ++ * Colors for button elements. ++ */ ++ colors?: ButtonColors; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++ /** ++ * Content to display inside the button, usually a chevron icon. ++ */ ++ children: ReactNode; ++} ++ ++type NativeLeadingButtonProps = Omit & ++ ViewEvent<'onButtonPressed', void>; ++ ++type NativeTrailingButtonProps = Omit & ++ ViewEvent<'onCheckedChange', { checked: boolean }>; ++ ++const SplitButtonLayoutNativeView: React.ComponentType = requireNativeView( ++ 'ExpoUI', ++ 'SplitButtonLayoutView' ++); ++const LeadingButtonNativeView: React.ComponentType = requireNativeView( ++ 'ExpoUI', ++ 'SplitButtonLeadingButtonView' ++); ++const TrailingButtonNativeView: React.ComponentType = requireNativeView( ++ 'ExpoUI', ++ 'SplitButtonTrailingButtonView' ++); ++const SlotNativeView: React.ComponentType<{ slotName: string; children: ReactNode }> = ++ requireNativeView('ExpoUI', 'SlotView'); ++ ++function LeadingButton(props: SplitButtonLeadingButtonProps) { ++ const { onClick, modifiers, ...restProps } = props; ++ return ( ++ ++ onClick() : undefined} ++ /> ++ ++ ); ++} ++ ++function TrailingButton(props: SplitButtonTrailingButtonProps) { ++ const { onCheckedChange, modifiers, ...restProps } = props; ++ return ( ++ ++ onCheckedChange(checked) : undefined ++ } ++ /> ++ ++ ); ++} ++ ++/** ++ * A Material 3 split button: a leading action button and a trailing toggle button, ++ * usually anchoring a `DropdownMenu` of related actions. ++ */ ++function SplitButtonLayout(props: SplitButtonLayoutProps) { ++ const { modifiers, ...restProps } = props; ++ return ( ++ ++ ); ++} ++ ++SplitButtonLayout.LeadingButton = LeadingButton; ++SplitButtonLayout.TrailingButton = TrailingButton; ++ ++export { SplitButtonLayout }; diff --git a/src/jetpack-compose/TopAppBar/index.tsx b/src/jetpack-compose/TopAppBar/index.tsx new file mode 100644 -index 0000000..ca69824 +index 0000000000000000000000000000000000000000..ca6982443d5711c48e90e79a57b794055efbfcb5 --- /dev/null +++ b/src/jetpack-compose/TopAppBar/index.tsx @@ -0,0 +1,28 @@ @@ -335,7 +773,7 @@ index 0000000..ca69824 +TopAppBar.NavigationIcon = function NavigationIcon({ children }: { children: ReactNode }) { return {children}; }; +TopAppBar.Actions = function Actions({ children }: { children: ReactNode }) { return {children}; }; diff --git a/src/jetpack-compose/index.ts b/src/jetpack-compose/index.ts -index 77fe3d7..3fa0fb9 100644 +index 77fe3d7412fb0ef423e3d7a870676ebb54f07350..08ab59f63c7b40f4727500a37d95bed4f262de82 100644 --- a/src/jetpack-compose/index.ts +++ b/src/jetpack-compose/index.ts @@ -1,3 +1,4 @@ @@ -343,3 +781,11 @@ index 77fe3d7..3fa0fb9 100644 import './MaterialSymbolsAssetsTransformer.fx'; export * from './AlertDialog'; +@@ -22,6 +23,7 @@ export * from './ListItem'; + export * from './RNHostView'; + export * from './DatePicker'; + export * from './SegmentedButton'; ++export * from './SplitButton'; + export * from './Progress'; + export * from './Slider'; + export * from './Spacer'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5f5495d42cf6..8c903ef002a9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -372,7 +372,7 @@ patchedDependencies: '@effect/vitest@4.0.5': 2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02 '@expo/fingerprint@0.21.5': 8fba80d6bbce46c72de427fe8c7907462d262a0bfe50c6b2b7ee9c4e0c61e5e1 '@expo/metro-config@58.0.14': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 - '@expo/ui@58.0.20': 5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e + '@expo/ui@58.0.20': 64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c '@legendapp/list@3.3.5': 6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 @@ -440,7 +440,7 @@ importers: dependencies: '@clerk/electron': specifier: 0.1.3 - version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -475,8 +475,8 @@ importers: specifier: 4.0.5 version: 4.0.5 electron: - specifier: 44.9.3 - version: 44.9.3(supports-color@8.1.1) + specifier: 44.10.0 + version: 44.10.0(supports-color@8.1.1) electron-store: specifier: ^8.2.0 version: 8.2.0 @@ -523,6 +523,9 @@ importers: apps/marketing: dependencies: + '@fontsource-variable/inter': + specifier: ^5.3.0 + version: 5.3.0 '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -562,7 +565,7 @@ importers: version: 58.0.14(@expo/log-box@58.0.12)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@expo/ui': specifier: 58.0.20 - version: 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.20(patch_hash=64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@legendapp/list': specifier: 3.3.5 version: 3.3.5(patch_hash=6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -921,6 +924,9 @@ importers: character-entities: specifier: 2.0.2 version: 2.0.2 + diff: + specifier: 8.0.3 + version: 8.0.3 effect: specifier: 4.0.5 version: 4.0.5 @@ -1032,7 +1038,7 @@ importers: version: 6.40.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/electron': specifier: 0.1.3 - version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/react': specifier: 6.18.0 version: 6.18.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -8464,8 +8470,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@44.9.3: - resolution: {integrity: sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==} + electron@44.10.0: + resolution: {integrity: sha512-ss4pCfrQKV4TWkuVYtWB4HmeysPZgB96cSYNKYZv1UVCRP4vQMGNW5oopFbvx6Bbfce1XtP5pm5RDuKehp0svQ==} engines: {node: '>= 22.12.0'} hasBin: true @@ -13241,12 +13247,12 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@clerk/electron@0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@clerk/clerk-js': 6.40.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/react': 6.18.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/shared': 4.41.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - electron: 44.9.3(supports-color@8.1.1) + electron: 44.10.0(supports-color@8.1.1) react: 19.3.0 tslib: 2.8.1 optionalDependencies: @@ -14373,7 +14379,7 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - ? '@expo/ui@58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)' + ? '@expo/ui@58.0.20(patch_hash=64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)' : dependencies: expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) react: 19.3.0 @@ -18087,7 +18093,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@44.9.3(supports-color@8.1.1): + electron@44.10.0(supports-color@8.1.1): dependencies: '@electron-internal/extract-zip': 1.0.5 '@electron/get': 5.1.0(supports-color@8.1.1) @@ -18624,7 +18630,7 @@ snapshots: ? expo-widgets@58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) : dependencies: '@expo/plist': 0.10.1 - '@expo/ui': 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + '@expo/ui': 58.0.20(patch_hash=64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 98f415c0d982c..228a8edcebc7f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -170,7 +170,7 @@ minimumReleaseAgeExclude: - expo-sharing@58.0.19 - expo-sqlite@58.0.13 - expo-symbols@58.0.4 - - electron@44.9.3 + - electron@44.10.0 - "@anthropic-ai/claude-agent-sdk@0.3.276" - "@shadcn/lint@0.1.5" - cn@0.3.2 diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index ee7c45ada5dd6..3c74b6714ddeb 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -251,6 +251,69 @@ const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, ta } }); +/** Node links libatomic.so.1, which Ubuntu server and container images lack, so t3 carries its own. */ +const bundleLinuxLibatomic = Effect.fn("bundleLinuxLibatomic")(function* (input: { + readonly repoRoot: string; + readonly contentDir: string; + readonly executablePath: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const ldd = yield* spawner.string(ChildProcess.make("ldd", [input.executablePath])); + const source = /libatomic\.so\.1 => (\/\S+)/.exec(ldd)?.[1]; + if (source === undefined) { + return yield* new CliArchiveInputMissingError({ + inputPath: "libatomic.so.1", + hint: `ldd did not resolve it for ${input.executablePath}. Install libatomic1 on the build host.`, + }); + } + yield* fs.makeDirectory(path.join(input.contentDir, "lib")); + yield* fs.copyFile(source, path.join(input.contentDir, "lib/libatomic.so.1")); + + // libatomic is GPL-3.0-or-later WITH GCC-exception-3.1, so its terms and source travel with it. + const owner = yield* spawner.string( + ChildProcess.make("dpkg-query", ["-S", yield* fs.realPath(source)]), + ); + // Keep the arch qualifier so a second installed arch cannot add its own line. + const [, qualifiedPkg, pkg] = /^(([^:\s]+)(?::[^:\s]+)?): /.exec(owner) ?? []; + const versions = qualifiedPkg + ? yield* spawner.string( + ChildProcess.make("dpkg-query", [ + "-W", + "-f=${Version} ${source:Package} ${source:Version}", + qualifiedPkg, + ]), + ) + : ""; + const [version, sourcePackage, sourceVersion] = versions.trim().split(" "); + if (!pkg || !version || !sourcePackage || !sourceVersion) { + return yield* new CliArchiveInputMissingError({ + inputPath: `dpkg package for ${source}`, + hint: "The archive copies libatomic's license and source reference from its Ubuntu package.", + }); + } + const header = [ + `libatomic.so.1 is copied from the Ubuntu package ${pkg} ${version}.`, + `Source: https://launchpad.net/ubuntu/+source/${sourcePackage}/${sourceVersion}`, + ].join("\n"); + // Vendored because CI runner images strip /usr/share/doc. + const license = yield* fs.readFileString( + path.join(input.repoRoot, "scripts/licenses/libatomic.LICENSE"), + ); + yield* fs.writeFileString( + path.join(input.contentDir, "lib/libatomic.so.1.LICENSE"), + `${header}\n\n${license}`, + ); + + // RUNPATH resolves through symlinks and is not inherited by child processes, unlike LD_LIBRARY_PATH. + yield* runCommand( + ChildProcess.make("patchelf", ["--set-rpath", "$ORIGIN/lib", input.executablePath]), + "patchelf --set-rpath", + ); + yield* Effect.log(`[cli-archive] Bundled ${source} as lib/libatomic.so.1.`); +}); + const MacSigningConfig = Config.all({ identity: Config.String("T3CODE_CLI_MAC_SIGN_IDENTITY").pipe(Config.option), appleApiKey: Config.String("APPLE_API_KEY").pipe(Config.option), @@ -444,11 +507,6 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { builtExecutable, `Run \`node apps/server/scripts/cli.ts build-exe${targetKey === hostKey ? "" : ` --target ${targetKey}`}\` first.`, ); - // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 - if (input.platform === "linux" && input.arch === "arm64") { - const bytes = yield* fs.readFile(builtExecutable); - yield* Effect.try(() => assertLinuxArm64PageCompatibility(bytes)); - } yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first."); yield* requireInput( resourceMonitorDir, @@ -473,6 +531,14 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { }); const executablePath = path.join(contentDir, executableName); + if (input.platform === "linux") { + yield* bundleLinuxLibatomic({ repoRoot, contentDir, executablePath }); + } + // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 + if (input.platform === "linux" && input.arch === "arm64") { + const bytes = yield* fs.readFile(executablePath); + yield* Effect.try(() => assertLinuxArm64PageCompatibility(bytes)); + } if (input.platform === "mac") { yield* signMacArchiveContents({ repoRoot, contentDir, executablePath }); } else if (input.platform === "win") { diff --git a/scripts/build-npm-launcher.test.ts b/scripts/build-npm-launcher.test.ts index 8dd98225cd073..749e27bd5454a 100644 --- a/scripts/build-npm-launcher.test.ts +++ b/scripts/build-npm-launcher.test.ts @@ -13,6 +13,8 @@ import * as NodeCrypto from "node:crypto"; import * as NodeEvents from "node:events"; import * as NodeHttp from "node:http"; +import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; + import { buildNpmLauncher } from "./build-npm-launcher.ts"; const VERSION = "1.2.3"; @@ -118,6 +120,7 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { assert.deepStrictEqual(manifest.bin, { t3: "./bin/t3.mjs" }); assert.isUndefined(manifest.dependencies); assert.isUndefined(manifest.optionalDependencies); + assert.isAbove(npmReleaseDatabaseMigration(manifest) ?? 0, 0); const installed = path.join(root, "installed"); yield* fs.makeDirectory(installed); diff --git a/scripts/build-npm-launcher.ts b/scripts/build-npm-launcher.ts index df1cc488814aa..bae124fb008a5 100644 --- a/scripts/build-npm-launcher.ts +++ b/scripts/build-npm-launcher.ts @@ -30,8 +30,8 @@ export class NpmLauncherCommandFailedError extends Schema.TaggedError=20.11" }, + t3code: { databaseMigration }, }; } +/** Highest server migration id, from the `NNN_Name.ts` files the server registers. */ +const latestServerMigrationId = Effect.fn("latestServerMigrationId")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* path.fromFileUrl( + new URL("../apps/server/src/persistence/Migrations/", import.meta.url), + ); + const ids = (yield* fs.readDirectory(dir)).flatMap((name) => { + const id = /^(\d+)_\w+\.ts$/.exec(name)?.[1]; + return id === undefined ? [] : [Number(id)]; + }); + return Math.max(0, ...ids); +}); + export const buildNpmLauncher = Effect.fn("buildNpmLauncher")(function* (input: { readonly version: string; readonly outputDir: string; @@ -64,7 +79,9 @@ export const buildNpmLauncher = Effect.fn("buildNpmLauncher")(function* (input: yield* fs.makeDirectory(path.join(stageDir, "dist")); yield* fs.writeFileString( path.join(stageDir, "package.json"), - `${yield* encodePackageJson(npmLauncherPackageManifest(input.version))}\n`, + `${yield* encodePackageJson( + npmLauncherPackageManifest(input.version, yield* latestServerMigrationId()), + )}\n`, ); yield* fs.copyFile(path.join(sourceDir, "t3.mjs"), path.join(stageDir, "bin/t3.mjs")); yield* fs.chmod(path.join(stageDir, "bin/t3.mjs"), 0o755); diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index 25630728e3d66..b854cf0a4e539 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -1,7 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off globalConsole:off // Runs CI's fast checks for this branch's changes: format and lint of the // changed files, knip, generated GraphQL operations, typecheck of the affected -// packages and their dependents, and the test files beside the changed files. +// packages and their dependents, the test files beside the changed files, and +// the cross-file checks those miss (agent context budget, React Compiler +// coverage of the changed client files). // A dependency change also gets the unused-patch install check below. CI still // runs the full test suites and the jobs listed in the summary. // @@ -138,6 +140,52 @@ for (const file of code) { if (entry.startsWith(prefix) && TEST.test(entry)) tests.add(NodePath.join(dir, entry)); } } +// It reads files other than the ones beside it, so no changed file selects it. +if (affected.includes("t3")) tests.add("apps/server/src/provider/AgentContextBudget.test.ts"); + +// React Compiler coverage compiles every client source file, so run only the +// changed files' cases, in one extra step: a -t filter in the main test step +// would drop every other test. An app is skipped when the main step already +// runs its coverage file unfiltered. File filters mirror each coverage test. +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const compilerApps = [ + { + test: "apps/web/src/reactCompilerCoverage.test.ts", + root: "apps/web/src/", + // Test names keep the repo-relative path. + name: (file: string) => file, + compiled: (file: string) => + /\.tsx?$/.test(file) && !/\.(test|browser)\.tsx?$/.test(file) && !file.endsWith(".gen.ts"), + // Web has only per-file cases. + always: [], + }, + { + test: "apps/mobile/src/reactCompilerCoverage.test.ts", + root: "apps/mobile/src/", + name: (file: string) => file.slice("apps/mobile/".length), + compiled: (file: string) => /\.tsx?$/.test(file) && !/\.(test\.tsx?|d\.ts)$/.test(file), + // The Metro, cache, and stale-allowlist cases check more than one file, deletions included. + always: ["^(?!.*compiles )"], + }, +].flatMap(({ test, root: appRoot, name, compiled, always }) => { + const changed = files.filter((file) => file.startsWith(appRoot) && compiled(file)); + if (changed.length === 0 || tests.has(test)) return []; + const names = changed + .filter((file) => present.includes(file)) + .map((file) => `compiles ${escapeRegExp(name(file))}$`); + const pattern = [...names, ...always]; + return pattern.length > 0 ? [{ test, names, pattern }] : []; +}); +const compilerCoverage = compilerApps.length > 0 && { + name: `react compiler (${compilerApps.reduce((count, app) => count + app.names.length, 0)} files)`, + args: [ + "test", + "run", + ...compilerApps.map((app) => app.test), + "-t", + compilerApps.flatMap((app) => app.pattern).join("|"), + ], +}; // knip:check cannot be scoped to a package. Any code change can leave a file, // export, or dependency unused (removing the last import of a helper does), so @@ -255,33 +303,42 @@ const steps = [ name: `test (${tests.size} files)`, args: ["test", "run", "--passWithNoTests", ...tests], }, + compilerCoverage, ].filter((step) => step !== false); // Output is piped, so keep the colors a terminal would get. const env = process.stdout.isTTY ? { ...process.env, FORCE_COLOR: "1" } : process.env; +const runStep = (step: (typeof steps)[number]) => + new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { + const started = performance.now(); + const finish = (ok: boolean, output?: Buffer) => { + const seconds = ((performance.now() - started) / 1000).toFixed(1); + console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); + if (output) process.stdout.write(output); + resolve({ name: step.name, ok, seconds }); + }; + // In-process steps print their own output. The patch check is first and + // takes about a second, so the commands start right after it. + if ("run" in step) return finish(step.run()); + const output: Array = []; + const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); + // A failed spawn emits "error", then "close" with a nonzero code. + child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); + child.on("close", (status) => finish(status === 0, Buffer.concat(output))); + }); + +// Vitest runs started together race to rebuild node_modules/.vitest-cache after +// an install (ENOTEMPTY), so they run one after another. +let previousVitest: Promise = Promise.resolve(); const results = await Promise.all( - steps.map( - (step) => - new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { - const started = performance.now(); - const finish = (ok: boolean, output?: Buffer) => { - const seconds = ((performance.now() - started) / 1000).toFixed(1); - console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); - if (output) process.stdout.write(output); - resolve({ name: step.name, ok, seconds }); - }; - // In-process steps print their own output. The patch check is first and - // takes about a second, so the commands start right after it. - if ("run" in step) return finish(step.run()); - const output: Array = []; - const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); - child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); - child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); - // A failed spawn emits "error", then "close" with a nonzero code. - child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); - child.on("close", (status) => finish(status === 0, Buffer.concat(output))); - }), - ), + steps.map((step) => { + if (!("args" in step) || step.args[0] !== "test") return runStep(step); + const result = previousVitest.then(() => runStep(step)); + previousVitest = result; + return result; + }), ); const summary = ["check:changed summary"]; diff --git a/scripts/coil/sync-upstream.sh b/scripts/coil/sync-upstream.sh index 7fa223f8cc433..52e1f38faba80 100755 --- a/scripts/coil/sync-upstream.sh +++ b/scripts/coil/sync-upstream.sh @@ -197,7 +197,8 @@ if ! git merge --no-ff --no-commit "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH"; then fi retire_upstream_workflows -if ! git commit -m "$MERGE_MSG"; then +# Upstream's pre-commit hook gates authoring, not merges; the verify step below is the gate. +if ! git commit --no-verify -m "$MERGE_MSG"; then FAILING_STEP="commit" write_status daily error "merge resolved but the merge commit could not be created" exit 1 diff --git a/scripts/coil/sync-upstream.test.ts b/scripts/coil/sync-upstream.test.ts index 1661915ac477d..1d4ce93f69990 100644 --- a/scripts/coil/sync-upstream.test.ts +++ b/scripts/coil/sync-upstream.test.ts @@ -71,6 +71,8 @@ it("keeps a retired workflow deleted when upstream modifies it", () => { NodeFS.writeFileSync(fixture.workflow, "name: upstream-ci-v2\n"); NodeFS.writeFileSync(NodePath.join(fixture.upstream, "upstream.txt"), "new\n"); commit(fixture.upstream, "modify retired workflow"); + const hook = NodePath.join(fixture.fork, ".git", "hooks", "pre-commit"); + NodeFS.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); const result = runSync(fixture.fork); diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts new file mode 100644 index 0000000000000..0c55b082dcfb9 --- /dev/null +++ b/scripts/generate-release-notes.test.ts @@ -0,0 +1,65 @@ +import { assert, it } from "@effect/vitest"; + +import * as Option from "effect/Option"; + +import { + countContributors, + isReleaseNoteCommit, + summaryText, + type PullRequest, +} from "./generate-release-notes.ts"; + +const pullRequest = (author: PullRequest["author"]): PullRequest => ({ + body: "", + additions: 1, + deletions: 1, + changedFiles: 1, + author, +}); + +it("counts each person once and leaves out bots", () => { + const commits = [ + { subject: "fix(web): one (#1)", authorName: "Ada Lovelace" }, + { subject: "fix(web): two (#2)", authorName: "Ada Lovelace" }, + { subject: "chore: bump (#3)", authorName: "dependabot[bot]" }, + { subject: "fix: direct push by a PR author", authorName: "Ada Lovelace" }, + { subject: "fix: direct push by someone else", authorName: "Grace Hopper" }, + { subject: "chore: direct push by a bot", authorName: "t3-code[bot]" }, + { subject: "fix(web): four (#4)", authorName: "Linus T" }, + { subject: "fix: direct push by a PR author with no profile name", authorName: "Linus T" }, + { subject: "fix(web): five, whose PR did not resolve (#5)", authorName: "Ken T" }, + ]; + const pullRequests = new Map([ + [1, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], + [2, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], + [3, pullRequest({ __typename: "Bot", login: "dependabot" })], + [4, pullRequest({ __typename: "User", login: "linus", name: null })], + ]); + + assert.equal(countContributors(commits, pullRequests), 4); +}); + +it("leaves mobile-only scopes out and keeps shared ones", () => { + const subjects = { + "fix(mobile): a": false, + "feat(android): b": false, + "fix(ios)!: c": false, + "fix(web,mobile): d": true, + "feat(mobile,web): f": true, + "fix(server): e": true, + }; + for (const [subject, kept] of Object.entries(subjects)) { + assert.equal(isReleaseNoteCommit({ subject, authorName: "" }), kept, subject); + } +}); + +it("uses model text only from a completed response", () => { + const output = [ + { type: "message", content: [{ type: "output_text", text: " ## Highlights\nX " }] }, + ]; + assert.deepStrictEqual( + summaryText({ status: "completed", output }), + Option.some("## Highlights\nX"), + ); + assert.deepStrictEqual(summaryText({ status: "incomplete", output }), Option.none()); +}); diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts new file mode 100644 index 0000000000000..909247dae1262 --- /dev/null +++ b/scripts/generate-release-notes.ts @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/cli"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +// Release notes cover web, desktop, and server. Mobile ships its own store notes. +const MOBILE_SCOPES = new Set(["mobile", "android", "ios"]); +const COMMIT_SCOPES = /^[a-z]+\(([^)]*)\)!?:/i; +const PULL_REQUEST_NUMBER = /\(#(\d+)\)$/; +const PULL_REQUEST_BODY_LIMIT = 300; +const SUMMARY_MODEL = "gpt-6.1-terra"; + +const SUMMARY_INSTRUCTIONS = `You write release notes for T3 Code, a GUI for coding agents (web and desktop apps backed by a server; providers like Codex, Claude, Cursor, OpenCode). + +These notes cover the web app, desktop app, and server only. The mobile app has its own release notes, so leave out anything only about mobile, iOS, or Android. + +You get the commit subjects merged since the last release, oldest first. Most end with a PR number like (#1234). Each commit shows lines added and removed and files changed, then a trimmed PR description on the next line starting with ">". Use the descriptions to understand what changed for users and why it matters. Line counts hint at size, but a small change can still matter most. + +Output Markdown in exactly this shape and nothing else: + +## Highlights +Up to 4 short sentences. Name only the new features a user would care about most. If no new feature matters to users, write one sentence about the most important fix. Each feature gets a few words that make the benefit clear, not a description of how it works or where the button is. + +## Fixes +- Up to 10 bullets, one fix each, under 10 words, ending with the PR number like (#123). Fewer is fine. Never pad. +- Each bullet names what was broken and says it works now. A user who hit the bug should recognize it. +- Only list fixes from fix commits. New abilities go in Highlights or nowhere. Say "again" only if the commit says it. + +How to write: +- Pick only significant, user-facing changes. Leave out visual polish (focus rings, spacing, colors), layout tweaks, refactors, tests, CI, build, docs, deps, internal tooling, and performance or efficiency work a user would not feel. +- Include Labs and beta features, with "(beta)" after the feature. Leave out work marked internal testing or dev-only. +- Describe each feature as it is at the end of the range. If a beta feature became standard later in the range, it is not beta. +- Say what the user gets, as short as possible. Cut details, conditions, and mechanics. +- No defaults or rollout state ("on by default", "now available", "behind a flag"). +- For lists of file types or formats, use common extensions: "previews for .docx, .ttf, .sqlite, .zip and more". +- No internal names, env vars, file paths, or code terms. +- No em dashes. No hype words. Do not invent anything that is not in the input. + +Examples of the tone: +- Bad: "Cmd+K now finds the right thread first, and it searches message text too." Good: "Much better Cmd+K search." +- Bad: "You can share a thread with a guest who watches it live and sends messages that you approve." Good: "Share threads with guests, safely." +- Bad: "Triage issues from the command palette." Good: "Fix and debug T3 Code itself with a new 'triage' command." +- Bad: "Settings and Usage open as large modals over your thread." Good: leave it out. +- Bad: "Cmd/Ctrl+W closes tabs safely (#25860)" Good: "Cmd/Ctrl+W no longer closes the whole window (#25860)"`; + +class EmptyReleaseRangeError extends Schema.TaggedError()( + "EmptyReleaseRangeError", + { range: Schema.String }, +) {} + +interface ReleaseCommit { + readonly subject: string; + readonly authorName: string; +} + +const PullRequest = Schema.Struct({ + body: Schema.String, + additions: Schema.Number, + deletions: Schema.Number, + changedFiles: Schema.Number, + author: Schema.NullOr( + Schema.Struct({ + __typename: Schema.String, + login: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); +export type PullRequest = typeof PullRequest.Type; + +const PullRequestQueryResponse = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Record(Schema.String, Schema.NullOr(PullRequest)), + }), +}); + +const GeneratedNotes = Schema.Struct({ body: Schema.String }); + +const SummaryResponse = Schema.Struct({ + status: Schema.String, + output: Schema.Array( + Schema.Struct({ + type: Schema.String, + content: Schema.optional( + Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) })), + ), + }), + ), + usage: Schema.optional( + Schema.Struct({ input_tokens: Schema.Number, output_tokens: Schema.Number }), + ), +}); + +const pullRequestNumber = (subject: string) => { + const match = PULL_REQUEST_NUMBER.exec(subject); + return match ? Number(match[1]) : undefined; +}; + +/** Drops a commit only when every scope is mobile, so `fix(web,mobile)` stays. */ +export function isReleaseNoteCommit(commit: ReleaseCommit): boolean { + const scopes = COMMIT_SCOPES.exec(commit.subject)?.[1]?.split(","); + return !scopes?.every((scope) => MOBILE_SCOPES.has(scope.trim().toLowerCase())); +} + +/** Keeps the prose of a PR body: no images, tables, headings, HTML, or verification sections. */ +function trimPullRequestBody(body: string, limit = PULL_REQUEST_BODY_LIMIT): string { + const [prose = ""] = body.split(/\n#+\s*(?:Verification|Council|Test|Testing|Screenshots)\b/i); + const text = prose + .split("\n") + .filter((line) => line.trim() !== "" && !/^\s*[!|#<]/.test(line)) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + return text.length > limit ? `${text.slice(0, limit)}...` : text; +} + +function buildSummaryInput( + commits: ReadonlyArray, + pullRequests: ReadonlyMap, +): string { + return commits + .flatMap(({ subject }) => { + const number = pullRequestNumber(subject); + const pullRequest = number === undefined ? undefined : pullRequests.get(number); + if (!pullRequest) return [subject]; + const line = `${subject} [+${pullRequest.additions} -${pullRequest.deletions}, ${pullRequest.changedFiles} files]`; + const body = trimPullRequestBody(pullRequest.body); + return body ? [line, ` > ${body}`] : [line]; + }) + .join("\n"); +} + +/** PR authors without bots, plus authors of direct pushes who have no PR in the range. */ +export function countContributors( + commits: ReadonlyArray, + pullRequests: ReadonlyMap, +): number { + const people = new Set(); + // A PR's squash commit carries its author's git name, which may differ from the profile name. + const prAuthorNames = new Set(); + const directAuthors: string[] = []; + for (const commit of commits) { + const number = pullRequestNumber(commit.subject); + const author = number === undefined ? undefined : pullRequests.get(number)?.author; + if (!author) { + directAuthors.push(commit.authorName); + continue; + } + if (author.__typename === "Bot" || author.login.endsWith("[bot]")) continue; + people.add(author.login); + prAuthorNames.add(commit.authorName); + if (author.name) prAuthorNames.add(author.name); + } + for (const name of directAuthors) { + if (!name.endsWith("[bot]") && !prAuthorNames.has(name)) people.add(name); + } + return people.size; +} + +/** No date here: the app shows how long ago a release shipped, and GitHub shows the date. */ +function buildReleaseHeader(version: string, changes: number, contributors: number): string { + const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; + return `**v${version.replace(/^v/, "")}** · ${plural(changes, "change")} from ${plural(contributors, "contributor")}`; +} + +/** The summary leads; GitHub's full list folds under it. Clients cut the body at `
    `. */ +function assembleReleaseNotes(header: string, summary: string, fullChangelog: string): string { + return `${header}\n\n${summary.trim()}\n\n
    \nFull changelog\n\n${fullChangelog.trim()}\n\n
    \n`; +} + +const run = Effect.fn("generateReleaseNotes.run")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner.string(ChildProcess.make(command, args)); +}); + +const decodeJson = (schema: S) => + Schema.decodeUnknownEffect(Schema.fromJsonString(schema)); + +const readCommits = Effect.fn("generateReleaseNotes.readCommits")(function* ( + previousTag: string, + ref: string, +) { + const log = yield* run("git", [ + "log", + "--reverse", + "--format=%s%x1f%an", + `${previousTag}..${ref}`, + ]); + return log + .split("\n") + .filter((line) => line !== "") + .map((line): ReleaseCommit => { + const [subject = "", authorName = ""] = line.split("\x1f"); + return { subject, authorName }; + }); +}); + +const readPullRequests = Effect.fn("generateReleaseNotes.readPullRequests")(function* ( + repository: string, + numbers: ReadonlyArray, +) { + const [owner, name] = repository.split("/"); + const pullRequests = new Map(); + for (let start = 0; start < numbers.length; start += 100) { + const fields = numbers + .slice(start, start + 100) + .map( + (number) => + `pr${number}: pullRequest(number: ${number}) { body additions deletions changedFiles author { __typename login ... on User { name } } }`, + ) + .join("\n"); + const query = `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields} } }`; + const response = yield* decodeJson(PullRequestQueryResponse)( + yield* run("gh", [ + "api", + "graphql", + "-f", + `query=${query}`, + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + ]), + ); + for (const [alias, pullRequest] of Object.entries(response.data.repository)) { + if (pullRequest) pullRequests.set(Number(alias.slice(2)), pullRequest); + } + } + return pullRequests; +}); + +const readFullChangelog = Effect.fn("generateReleaseNotes.readFullChangelog")(function* (options: { + readonly repository: string; + readonly tag: string; + readonly ref: string; + readonly previousTag: string; +}) { + const response = yield* run("gh", [ + "api", + `repos/${options.repository}/releases/generate-notes`, + "-f", + `tag_name=${options.tag}`, + "-f", + `target_commitish=${options.ref}`, + "-f", + `previous_tag_name=${options.previousTag}`, + ]); + return (yield* decodeJson(GeneratedNotes)(response)).body; +}); + +export function summaryText(response: typeof SummaryResponse.Type): Option.Option { + // An incomplete response can carry partial text; publishing it would cut the notes short. + if (response.status !== "completed") return Option.none(); + const text = response.output + .flatMap((item) => item.content ?? []) + .flatMap((part) => (part.type === "output_text" && part.text ? [part.text] : [])) + .join("") + .trim(); + return text === "" ? Option.none() : Option.some(text); +} + +const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: string) { + const apiKey = yield* Config.Redacted("OPENAI_API_KEY"); + const httpClient = yield* HttpClient.HttpClient; + const response = yield* HttpClientRequest.post("https://api.openai.com/v1/responses").pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.bodyJson({ + model: SUMMARY_MODEL, + reasoning: { effort: "low" }, + instructions: SUMMARY_INSTRUCTIONS, + input: `Commits:\n${input}`, + }), + Effect.flatMap(httpClient.execute), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap(Schema.decodeUnknownEffect(SummaryResponse)), + Effect.timeout("3 minutes"), + ); + yield* Effect.logInfo("release summary generated").pipe( + Effect.annotateLogs({ + model: SUMMARY_MODEL, + inputTokens: response.usage?.input_tokens, + outputTokens: response.usage?.output_tokens, + }), + ); + return summaryText(response); +}); + +const command = Command.make( + "generate-release-notes", + { + repository: Flag.String("repository").pipe(Flag.withDescription("GitHub owner/name.")), + tag: Flag.String("tag").pipe(Flag.withDescription("Tag being released.")), + ref: Flag.String("ref").pipe(Flag.withDescription("Commit being released.")), + previousTag: Flag.String("previous-tag").pipe(Flag.withDescription("Previous release tag.")), + version: Flag.String("version").pipe(Flag.withDescription("Version shown in the header.")), + output: Flag.String("output").pipe(Flag.withDescription("File to write the notes to.")), + }, + ({ repository, tag, ref, previousTag, version, output }) => + Effect.gen(function* () { + // The header counts every commit in the range; the summary skips mobile-only work. + const allCommits = yield* readCommits(previousTag, ref); + // The spawner ignores exit codes, so a failed git log shows up as an empty range. + if (allCommits.length === 0) { + return yield* new EmptyReleaseRangeError({ range: `${previousTag}..${ref}` }); + } + const commits = allCommits.filter(isReleaseNoteCommit); + const numbers = [ + ...new Set(allCommits.flatMap(({ subject }) => pullRequestNumber(subject) ?? [])), + ]; + const [pullRequests, fullChangelog] = yield* Effect.all( + [ + readPullRequests(repository, numbers), + readFullChangelog({ repository, tag, ref, previousTag }), + ], + { concurrency: "unbounded" }, + ); + const header = buildReleaseHeader( + version, + allCommits.length, + countContributors(allCommits, pullRequests), + ); + // With no summary, write nothing: the release falls back to GitHub's generated notes. + const summary = + commits.length === 0 + ? Option.none() + : yield* summarize(buildSummaryInput(commits, pullRequests)); + if (Option.isNone(summary)) { + return yield* Effect.logWarning("no release summary, leaving GitHub's notes in place"); + } + const fs = yield* FileSystem.FileSystem; + // Publish uses the file when it exists, so a partial write must never land at `output`. + yield* fs.writeFileString( + `${output}.tmp`, + assembleReleaseNotes(header, summary.value, fullChangelog), + ); + yield* fs.rename(`${output}.tmp`, output); + yield* Effect.logInfo("release notes written").pipe( + Effect.annotateLogs({ output, commits: commits.length }), + ); + }), +).pipe( + Command.withDescription("Write release notes with a model summary over GitHub's full list."), +); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide( + Layer.mergeAll( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + FetchHttpClient.layer, + ), + ), + NodeRuntime.runMain, + ); +} diff --git a/scripts/licenses/libatomic.LICENSE b/scripts/licenses/libatomic.LICENSE new file mode 100644 index 0000000000000..bea77366d7f51 --- /dev/null +++ b/scripts/licenses/libatomic.LICENSE @@ -0,0 +1,753 @@ +libatomic is part of GCC. Copyright (C) Free Software Foundation, Inc. +It is licensed under GPL-3.0-or-later WITH GCC-exception-3.1. Both texts follow. + + +GCC RUNTIME LIBRARY EXCEPTION + +Version 3.1, 31 March 2009 + +Copyright (C) 2009 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This GCC Runtime Library Exception ("Exception") is an additional +permission under section 7 of the GNU General Public License, version +3 ("GPLv3"). It applies to a given file (the "Runtime Library") that +bears a notice placed by the copyright holder of the file stating that +the file is governed by GPLv3 along with this Exception. + +When you use GCC to compile a program, GCC may combine portions of +certain GCC header files and runtime libraries with the compiled +program. The purpose of this Exception is to allow compilation of +non-GPL (including proprietary) programs to use, in this way, the +header files and runtime libraries covered by this Exception. + +0. Definitions. + +A file is an "Independent Module" if it either requires the Runtime +Library for execution after a Compilation Process, or makes use of an +interface provided by the Runtime Library, but is not otherwise based +on the Runtime Library. + +"GCC" means a version of the GNU Compiler Collection, with or without +modifications, governed by version 3 (or a specified later version) of +the GNU General Public License (GPL) with the option of using any +subsequent versions published by the FSF. + +"GPL-compatible Software" is software whose conditions of propagation, +modification and use would permit combination with GCC in accord with +the license of GCC. + +"Target Code" refers to output from any compiler for a real or virtual +target processor architecture, in executable form or suitable for +input to an assembler, loader, linker and/or execution +phase. Notwithstanding that, Target Code does not include data in any +format that is used as a compiler intermediate representation, or used +for producing a compiler intermediate representation. + +The "Compilation Process" transforms code entirely represented in +non-intermediate languages designed for human-written code, and/or in +Java Virtual Machine byte code, into Target Code. Thus, for example, +use of source code generators and preprocessors need not be considered +part of the Compilation Process, since the Compilation Process can be +understood as starting with the output of the generators or +preprocessors. + +A Compilation Process is "Eligible" if it is done using GCC, alone or +with other GPL-compatible software, or if it is done without using any +work based on GCC. For example, using non-GPL-compatible Software to +optimize any GCC intermediate representations would not qualify as an +Eligible Compilation Process. + +1. Grant of Additional Permission. + +You have permission to propagate a work of Target Code formed by +combining the Runtime Library with Independent Modules, even if such +propagation would otherwise violate the terms of GPLv3, provided that +all Target Code was generated by Eligible Compilation Processes. You +may then convey such a combination under terms of your choice, +consistent with the licensing of the Independent Modules. + +2. No Weakening of GCC Copyleft. + +The availability of this Exception does not imply any general +presumption that third-party software is unaffected by the copyleft +requirements of the license of GCC. + + + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 0eb3bf348530a..29d94561c8fbc 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -135,6 +135,12 @@ const suppressMentions = (text: string) => const escapeLinkLabel = (text: string) => text.replace(/[\\[\]]/g, "\\$&"); +/** GitHub links `(#123)` on the release page; Discord needs the link written out. */ +function linkPullRequestNumbers(notes: string, releaseUrl: URL) { + const repositoryUrl = releaseUrl.href.split("/releases/")[0]; + return notes.replace(/#(\d+)\b/g, `[#$1](${repositoryUrl}/pull/$1)`); +} + function formatReleaseNotes(notes: string) { return notes .replace( @@ -193,9 +199,15 @@ export const buildDiscordReleaseAnnouncement = ( options.target === "prerelease" ? "A new T3 Code prerelease is available for nightly testers." : `[View full release notes on GitHub](${options.releaseUrl.href})`; + // Generated summaries fold the full list under
    , which Discord cannot render. + const [summary = "", folded] = (options.releaseNotes ?? "").split(/^
    /m); const notes = options.target === "prerelease" - ? suppressMentions(formatReleaseNotes(options.releaseNotes?.trim() ?? "")) + ? suppressMentions( + folded === undefined + ? formatReleaseNotes(summary.trim()) + : `${linkPullRequestNumbers(summary.trim(), options.releaseUrl)}\n\n[Full changelog](${options.releaseUrl.href})`, + ) : ""; const description = notes ? `${intro}\n\n${notes}` : intro; // Separate embeds avoid URL deduplication; reserve title and footer space within Discord's 6000-character limit. diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index c5344bdeb43fe..1318aa3c0c6dd 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -97,7 +97,12 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { } const contentDir = path.join(scratch, root); const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3"); - for (const required of [executable, path.join(contentDir, "client/index.html")]) { + const bundledLibatomic = path.join(contentDir, "lib/libatomic.so.1"); + for (const required of [ + executable, + path.join(contentDir, "client/index.html"), + ...(platform === "linux" ? [bundledLibatomic, `${bundledLibatomic}.LICENSE`] : []), + ]) { if (!(yield* fs.exists(required))) { return yield* new CliArchiveSmokeError({ step: "checking the archive layout", @@ -106,6 +111,22 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { } } + // The runner has libatomic installed; fresh Ubuntu servers do not. + if (platform === "linux") { + const ldd = yield* spawner.string(ChildProcess.make("ldd", [executable])); + // Older glibc prints the path as given, newer prints it resolved; compare both resolved. + const loaded = /libatomic\.so\.1 => (\/.*) \(0x[0-9a-f]+\)/.exec(ldd)?.[1]; + if ( + loaded === undefined || + (yield* fs.realPath(loaded)) !== (yield* fs.realPath(bundledLibatomic)) + ) { + return yield* new CliArchiveSmokeError({ + step: "checking that t3 loads the bundled libatomic", + detail: ldd, + }); + } + } + const version = yield* runExecutable(executable, ["--version"], contentDir); if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) { return yield* new CliArchiveSmokeError({ diff --git a/vite.config.ts b/vite.config.ts index 909becac31779..468837c255686 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -149,7 +149,11 @@ export default defineConfig({ : {}), }, staged: { - "*": "vp fmt --no-error-on-unmatched-pattern", + // In order, so lint reads the formatted file. CI lint failures cost a full round trip. + "*": ["vp fmt --no-error-on-unmatched-pattern", "vp lint --no-error-on-unmatched-pattern"], + // knip checks the whole repo, so it takes no file list. Any code or manifest change can leave + // a file, export, or dependency unused. + "{*.{js,jsx,ts,tsx,mjs,cjs,mts,cts},package.json,knip.jsonc}": () => "vp run knip:check", }, fmt: { ignorePatterns: [